// 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 }