niko_trust/internal/bft/bft.go
Niko Marmeladkov 20cc52c3a5 feat: network layer — PoW, checkpoint chain, gossip, light node, WS, delegation, rotation, BFT
- BLAKE3 keyed proof-of-work on object storage and auth challenges,
  with frozen vectors cross-checked against an independent Python
  reference implementing the single-block hash it needs.
- Sparse Merkle trie over object IDs: order-independent roots,
  inclusion and absence proofs (internal/smt).
- Signed checkpoint chain per relay: transport key amendment to INV-1,
  /v1/checkpoint/* and inclusion/absence proof endpoints, restart-safe
  epoch continuity (internal/checkpoint).
- Head gossip with TOFU pinning and equivocation detection; light node
  (cmd/lightnode) that stores no history: quorum of pinned relays,
  every served object proven against the agreed root, LRU disk cache.
- WebSocket streaming on relay and light node (coder/websocket):
  scoped channels mirroring REST, raw envelopes verified client-side;
  light node marks streamed objects unproven until checkpoint coverage.
- Protocol v1 additions: DelegationClaim tag 0x07 with deterministic
  chain resolution in verify.Graph, KeyRotationRequest/Confirm tags
  0x08/0x09 with hash-bound two-sided consent and Policy.RotationMaxAge;
  spec sections, frozen vectors appended byte-identically, Python
  reference extended.
- Optional permissioned BFT finality over gossip (internal/bft):
  prevote/precommit with quorum certificates verifiable offline.
- Quick wins: Policy.TrustedIssuers, per-type stored metrics,
  batch fetch, lexicographic lists with stable cursor pagination.
- Security review of the network layer (docs/SECURITY-REVIEW.md) with
  findings F-01..F-09; hub send/close race and unstable pagination
  fixed under review.

12 packages green, vet/gofmt clean, protocol fuzzing stable.
2026-08-25 20:38:40 +03:00

347 lines
10 KiB
Go

// Package bft implements permissioned Byzantine-fault-tolerant finality for
// the checkpoint chain.
//
// Model. A fixed set of relays act as validators (their signing key is the
// same transport key that signs their checkpoints). For every height they
// agree on exactly one head through two voting phases, prevote then
// precommit, requiring a quorum of distinct validators in each. A height
// finalizes with a certificate: quorum precommits over the same canonical
// bytes. Anyone holding the validator set can verify a certificate offline —
// running a validator is not required to check finality.
//
// Safety: two conflicting certificates for one height imply ≥⅓ Byzantine
// validators (standard two-phase argument), because a precommit for X is
// issued only after a quorum prevoted X, and signatures are unforgeable.
// Liveness: rounds rotate the proposer deterministically; a stalled round
// times out into the next. The chain rule — a proposal is valid only if its
// head links to the last finalized ID — makes forks visible instead of
// silent.
//
// Deliberate simplifications for a permissioned v1 are listed in
// docs/BFT.md; none touch the safety argument above.
package bft
import (
"crypto/ed25519"
"crypto/sha256"
"encoding/hex"
"errors"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
)
// Phases of the protocol, encoded inside the signed message domain.
type Phase byte
const (
PhasePrevote Phase = 0x01
PhasePrecommit Phase = 0x02
)
// Errors reported by message validation.
var (
ErrBadSignature = errors.New("bft: signature does not verify")
ErrUnknownVoter = errors.New("bft: signer is not a validator")
ErrBadMessage = errors.New("bft: malformed message")
)
// Message is one signed vote: a prevote or precommit for a height/round and
// head. A zero HeadID encodes an explicit nil vote ("I saw no valid head").
type Message struct {
Phase Phase
Height uint64
Round uint64
HeadID [32]byte
pubkey ed25519.PublicKey
sig []byte
}
// SetProposal frames what the round's proposer offers: its latest head.
type Proposal struct {
Height uint64
Round uint64
HeadID [32]byte
}
func domain(p Phase) []byte {
switch p {
case PhasePrevote:
return []byte("trust.n1ko.dev/bft/prevote/1\x00")
case PhasePrecommit:
return []byte("trust.n1ko.dev/bft/precommit/1\x00")
}
return nil
}
func propDomain() []byte { return []byte("trust.n1ko.dev/bft/proposal/1\x00") }
// Encode returns the canonical bytes of a vote message.
func (m *Message) Encode() ([]byte, error) {
if m.Phase != PhasePrevote && m.Phase != PhasePrecommit {
return nil, ErrBadMessage
}
e := tce.NewEncoder()
e.Uvarint(m.Height)
e.Uvarint(m.Round)
e.FixedBytes("head_id", m.HeadID[:], 32)
body, err := e.Bytes()
if err != nil {
return nil, err
}
out := make([]byte, 0, len(domain(m.Phase))+len(body))
out = append(out, domain(m.Phase)...)
return append(out, body...), nil
}
// Sign produces the wire form: bytes, signature and the signer's public key.
func (m *Message) Sign(key ed25519.PrivateKey) (*Signed, error) {
b, err := m.Encode()
if err != nil {
return nil, err
}
if len(key) != ed25519.PrivateKeySize {
return nil, ErrBadSignature
}
return &Signed{
Bytes: b,
Signature: ed25519.Sign(key, b),
PubKeyHex: hex.EncodeToString(key.Public().(ed25519.PublicKey)),
}, nil
}
// Signed is the wire form of any signed BFT object.
type Signed struct {
Bytes []byte `json:"bytes"`
Signature []byte `json:"signature"`
PubKeyHex string `json:"public_key"`
}
// VerifyVote checks a signed vote and decodes it.
func VerifyVote(s *Signed) (*Message, error) {
if s == nil || len(s.Bytes) < 34 {
return nil, ErrBadMessage
}
pub, err := hex.DecodeString(s.PubKeyHex)
if err != nil || len(pub) != ed25519.PublicKeySize {
return nil, ErrUnknownVoter
}
if len(s.Signature) != ed25519.SignatureSize ||
!ed25519.Verify(ed25519.PublicKey(pub), s.Bytes, s.Signature) {
return nil, ErrBadSignature
}
// Decode strictly against both domains: the phase byte lives in the
// prefix, so a prevote can never be replayed as a precommit.
var m Message
for _, phase := range []Phase{PhasePrevote, PhasePrecommit} {
d := domain(phase)
if string(s.Bytes[:len(d)]) == string(d) {
m.Phase = phase
body := s.Bytes[len(d):]
d2 := tce.NewDecoder(body)
h, err := d2.Uvarint()
if err != nil {
return nil, ErrBadMessage
}
m.Height = h
r, err := d2.Uvarint()
if err != nil {
return nil, ErrBadMessage
}
m.Round = r
rest, err := d2.FixedBytes(32)
if err != nil {
return nil, ErrBadMessage
}
copy(m.HeadID[:], rest)
if d2.End() != nil {
return nil, ErrBadMessage
}
m.pubkey = ed25519.PublicKey(pub)
m.sig = s.Signature
return &m, nil
}
}
return nil, ErrBadMessage
}
// Propose signs a proposal with the validator key.
func Propose(p *Proposal, key ed25519.PrivateKey) (*Signed, error) {
if len(key) != ed25519.PrivateKeySize {
return nil, ErrBadSignature
}
e := tce.NewEncoder()
e.Uvarint(p.Height)
e.Uvarint(p.Round)
e.FixedBytes("head_id", p.HeadID[:], 32)
body, err := e.Bytes()
if err != nil {
return nil, err
}
buf := make([]byte, 0, len(propDomain())+len(body))
buf = append(buf, propDomain()...)
buf = append(buf, body...)
return &Signed{
Bytes: buf,
Signature: ed25519.Sign(key, buf),
PubKeyHex: hex.EncodeToString(key.Public().(ed25519.PublicKey)),
}, nil
}
// VerifyProposal checks a proposal was signed by the expected proposer for
// this height/round.
func VerifyProposal(s *Signed, set *Set, height, round uint64) (*Proposal, error) {
pub, err := hex.DecodeString(s.PubKeyHex)
if err != nil || len(pub) != ed25519.PublicKeySize {
return nil, ErrUnknownVoter
}
want := set.Proposer(height, round)
if string(want) != string(pub) {
return nil, ErrUnknownVoter // not this round's proposer
}
if len(s.Signature) != ed25519.SignatureSize ||
!ed25519.Verify(ed25519.PublicKey(pub), s.Bytes, s.Signature) {
return nil, ErrBadSignature
}
d := propDomain()
if len(s.Bytes) <= len(d)+32 || string(s.Bytes[:len(d)]) != string(d) {
return nil, ErrBadMessage
}
body := s.Bytes[len(d):]
d2 := tce.NewDecoder(body)
h, err := d2.Uvarint()
if err != nil || h != height {
return nil, ErrBadMessage
}
r, err := d2.Uvarint()
if err != nil || r != round {
return nil, ErrBadMessage
}
var p Proposal
p.Height, p.Round = h, r
head, err := d2.FixedBytes(32)
if err != nil || d2.End() != nil {
return nil, ErrBadMessage
}
copy(p.HeadID[:], head)
return &p, nil
}
// Set is the validator set: public keys plus reachable URLs, index-aligned.
type Set struct {
PubKeys []ed25519.PublicKey
URLs []string
}
// NewSet parses hex public keys paired with base URLs.
func NewSet(hexKeys []string, urls []string) (*Set, error) {
if len(hexKeys) != len(urls) {
return nil, errors.New("bft: validator keys and URLs must align")
}
s := &Set{}
for _, hk := range hexKeys {
raw, err := hex.DecodeString(hk)
if err != nil || len(raw) != ed25519.PublicKeySize {
return nil, errors.New("bft: bad validator pubkey")
}
s.PubKeys = append(s.PubKeys, ed25519.PublicKey(raw))
}
s.URLs = append(s.URLs, urls...)
return s, nil
}
// Quorum is the number of votes needed: 2f+1 where f tolerates Byzantine
// validators. A set of n tolerates f=(n-1)/3.
func (s *Set) Quorum() int {
n := len(s.PubKeys)
f := (n - 1) / 3
return 2*f + 1
}
// IsValidator reports whether pub belongs to the set.
func (s *Set) IsValidator(pub ed25519.PublicKey) bool {
for _, k := range s.PubKeys {
if string(k) == string(pub) {
return true
}
}
return false
}
// Proposer deterministically names the validator expected to propose for a
// height/round: SHA-256(height||round) selects the leader, so every node
// computes the same answer and equivocation by anyone else is ignored.
func (s *Set) Proposer(height, round uint64) ed25519.PublicKey {
sum := sha256.Sum256([]byte{byte(height >> 56), byte(height >> 48), byte(height >> 40), byte(height >> 32),
byte(height >> 24), byte(height >> 16), byte(height >> 8), byte(height),
byte(round >> 56), byte(round >> 48), byte(round >> 40), byte(round >> 32),
byte(round >> 24), byte(round >> 16), byte(round >> 8), byte(round)})
idx := int(sum[0]) % len(s.PubKeys)
return s.PubKeys[idx]
}
// Certificate assembles precommits for finality verification.
type Certificate struct {
Height uint64 `json:"height"`
HeadID string `json:"head_id"`
Precommits []Signed `json:"precommits"`
}
// Finalize attempts to build a certificate from collected precommits. It
// returns nil until quorum distinct validators have precommitted this exact
// head at this exact height/round.
func (s *Set) Finalize(height, round uint64, headID [32]byte, precommits []*Signed) *Certificate {
seen := make(map[string]struct{}, len(precommits))
var keep []Signed
for _, pc := range precommits {
m, err := VerifyVote(pc)
if err != nil || m.Phase != PhasePrecommit || m.Height != height ||
m.Round > round || m.HeadID != headID {
continue
}
k := pc.PubKeyHex
if _, dup := seen[k]; dup {
continue // one vote per validator
}
seen[k] = struct{}{}
keep = append(keep, *pc)
if len(seen) >= s.Quorum() {
return &Certificate{
Height: height,
HeadID: hex.EncodeToString(headID[:]),
Precommits: keep,
}
}
}
return nil
}
// VerifyCertificate checks a certificate offline: enough distinct validators,
// every precommit authentic, all agreeing on the claimed head and height.
func (s *Set) VerifyCertificate(c *Certificate) error {
var headID [32]byte
idb, err := hex.DecodeString(c.HeadID)
if err != nil || len(idb) != 32 {
return ErrBadMessage
}
copy(headID[:], idb)
seen := make(map[string]struct{}, len(c.Precommits))
for i := range c.Precommits {
pc := &c.Precommits[i]
m, err := VerifyVote(pc)
if err != nil || m.Phase != PhasePrecommit || m.Height != c.Height || m.HeadID != headID {
return ErrBadMessage
}
if !s.IsValidator(m.pubkey) {
return ErrUnknownVoter
}
if _, dup := seen[pc.PubKeyHex]; dup {
return ErrBadMessage
}
seen[pc.PubKeyHex] = struct{}{}
}
if len(seen) < s.Quorum() {
return errors.New("bft: certificate lacks quorum")
}
return nil
}