- 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.
347 lines
10 KiB
Go
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
|
|
}
|