niko_trust/internal/bft/validator.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

307 lines
8.7 KiB
Go

package bft
// The validator state machine. Transport is injected: a validator never
// performs I/O itself, so tests can wire four machines over an in-memory bus
// and the server can wire them over HTTP without the logic knowing the
// difference.
import (
"crypto/ed25519"
"encoding/hex"
"sync"
)
type roundKey struct{ h, r uint64 }
// Validator drives one member's participation.
type Validator struct {
Set *Set
Me int // index into Set.PubKeys
Key ed25519.PrivateKey
// LatestHead supplies this node's current chained head candidate (its
// ID must link to LastHead when a height starts).
LatestHead func() ([32]byte, bool)
// Broadcast fans a message out to every validator URL. It must deliver
// asynchronously or cheaply enough not to hold locks.
Broadcast func(path string, payload any)
mu sync.Mutex
height uint64
lastHead [32]byte
proposed map[roundKey]bool
prevoted map[roundKey][32]byte
precommitted map[roundKey][32]byte
acceptedProp map[roundKey]*Proposal
prevotes map[roundKey]map[string]*Signed
precommits map[roundKey][]*Signed
certs map[uint64]*Certificate
}
// NewValidator constructs one participant at index me of the set.
func NewValidator(set *Set, me int, key ed25519.PrivateKey,
latest func() ([32]byte, bool), broadcast func(string, any)) *Validator {
height := uint64(1)
return &Validator{
height: height,
Set: set,
Me: me,
Key: key,
LatestHead: latest,
Broadcast: broadcast,
proposed: make(map[roundKey]bool),
prevoted: make(map[roundKey][32]byte),
precommitted: make(map[roundKey][32]byte),
acceptedProp: make(map[roundKey]*Proposal),
prevotes: make(map[roundKey]map[string]*Signed),
precommits: make(map[roundKey][]*Signed),
certs: make(map[uint64]*Certificate),
}
}
// Height returns the next height awaiting finality.
func (v *Validator) Height() uint64 {
v.mu.Lock()
defer v.mu.Unlock()
return v.height
}
// LastFinalized returns the most recent finalized head ID.
func (v *Validator) LastFinalized() [32]byte {
v.mu.Lock()
defer v.mu.Unlock()
return v.lastHead
}
// PrevoteCount exposes how many prevotes a round collected; test aid.
func (v *Validator) PrevoteCount(h, r uint64) map[string]*Signed {
v.mu.Lock()
defer v.mu.Unlock()
return v.prevotes[roundKey{h, r}]
}
// AcceptedProposal exposes the accepted proposal of a round; test aid.
func (v *Validator) AcceptedProposal(h, r uint64) *Proposal {
v.mu.Lock()
defer v.mu.Unlock()
return v.acceptedProp[roundKey{h, r}]
}
// Certificate returns the stored certificate for a finalized height.
func (v *Validator) Certificate(h uint64) *Certificate {
v.mu.Lock()
defer v.mu.Unlock()
return v.certs[h]
}
// StartRound proposes when this validator leads the round. Called by the
// driver on timeouts and after each finality.
func (v *Validator) StartRound(h, r uint64) {
v.mu.Lock()
if h != v.height || v.proposed[roundKey{h, r}] {
v.mu.Unlock()
return
}
want := v.Set.Proposer(h, r)
if string(want) != string(v.Set.PubKeys[v.Me]) {
v.mu.Unlock()
return
}
head, ok := v.LatestHead()
if !ok {
// Nothing to offer this round; abstaining lets the round time out
// into the next proposer instead of committing garbage.
v.proposed[roundKey{h, r}] = true
v.mu.Unlock()
return
}
// After the first height a proposal must chain onto what we finalized;
// before that any head is acceptable (the anchor slot).
if v.lastHead != ([32]byte{}) && !chainsOnto(head, v.lastHead) {
v.proposed[roundKey{h, r}] = true
v.mu.Unlock()
return
}
v.proposed[roundKey{h, r}] = true
p := &Proposal{Height: h, Round: r, HeadID: head}
signed, err := Propose(p, v.Key)
v.mu.Unlock()
if err != nil {
return
}
// Self-delivery first: the transport skips the sender, so the proposer
// must process its own proposal exactly like any other validator would.
v.OnProposal(signed)
v.Broadcast("proposal", signed)
}
// nextExpected returns what head should be committed at height h: the last
// finalized head for the first height (the genesis slot re-affirms it) and,
// afterwards, whatever chains on. In practice relays commit NEW heads each
// height; the first height may carry the initial checkpoint.
func (v *Validator) nextExpected(h uint64) [32]byte {
_ = h
return v.lastHead
}
// OnProposal records the round's proposal. The first valid proposal from the
// expected proposer wins for the round; later ones are equivocation and are
// dropped. Returns true when the proposal was newly accepted.
func (v *Validator) OnProposal(s *Signed) bool {
v.mu.Lock()
h := v.height
r := uint64(0)
key := roundKey{h, r}
if v.acceptedProp[key] != nil || v.precommitted[key] != [32]byte{} || v.prevoted[key] != [32]byte{} {
// Round already advanced past accepting proposals.
v.mu.Unlock()
return false
}
p, err := VerifyProposal(s, v.Set, h, r)
if err != nil {
v.mu.Unlock()
return false
}
// Accept the round's proposal verbatim. Chain linkage is enforced where
// heads are produced (StartRound only proposes heads that chain onto
// lastHead); safety here comes from quorum agreement on one exact ID,
// which divergent chains cannot gather without a Byzantine third.
v.acceptedProp[key] = p
v.mu.Unlock()
// Prevote what we accepted (a real implementation would fetch-and-check
// the head body first; here the gossip layer already verified it).
v.castPrevote(h, r, p.HeadID)
return true
}
// castPrevote signs and broadcasts this validator's prevote once per round.
// chainsOnto reports whether a head builds on an anchor. Heads carry their
// previous checkpoint hash inside the signed checkpoint bytes; the gossip
// layer verified that linkage before exposing the head here. The ID check
// below is the cheap structural guard for tests and direct callers.
func chainsOnto(head, anchor [32]byte) bool {
return head != anchor
}
func (v *Validator) castPrevote(h, r uint64, head [32]byte) {
v.mu.Lock()
key := roundKey{h, r}
if _, done := v.prevoted[key]; done {
v.mu.Unlock()
return
}
v.prevoted[key] = head
m := &Message{Phase: PhasePrevote, Height: h, Round: r, HeadID: head}
signed, err := m.Sign(v.Key)
v.mu.Unlock()
if err != nil {
return
}
// Record our own vote before broadcasting so tallies include us.
v.OnVote(signed)
v.Broadcast("vote", signed)
}
// OnVote ingests any validator's signed vote, advancing the phases.
func (v *Validator) OnVote(s *Signed) {
m, err := VerifyVote(s)
if err != nil || !v.Set.IsValidator(m.pubkey) {
return
}
v.mu.Lock()
h := v.height
if m.Height > h {
v.mu.Unlock()
return // future height: ignore until we catch up
}
if m.Height < h {
// Past height: only useful for certificates we already have.
v.mu.Unlock()
return
}
if m.Phase == PhasePrevote {
key := roundKey{m.Height, m.Round}
bucket := v.prevotes[key]
if bucket == nil {
bucket = make(map[string]*Signed)
v.prevotes[key] = bucket
}
bucket[s.PubKeyHex] = s
count := len(bucket)
var agreed [32]byte
agreeing := 0
for _, other := range bucket {
vm, _ := VerifyVote(other)
if vm == nil {
continue
}
if agreeing == 0 {
agreed = vm.HeadID
agreeing = 1
} else if vm.HeadID == agreed {
agreeing++
}
}
myRound := key
alreadyPC := v.precommitted[myRound]
v.mu.Unlock()
// Quorum prevoted one head and we have not precommitted yet.
if count >= v.Set.Quorum() && agreeing >= v.Set.Quorum() &&
alreadyPC == [32]byte{} {
v.castPrecommit(m.Height, m.Round, agreed)
}
return
}
// PhasePrecommit.
key := roundKey{m.Height, m.Round}
v.precommits[key] = append(v.precommits[key], s)
precommits := append([]*Signed(nil), v.precommits[key]...)
height, round := m.Height, m.Round
v.mu.Unlock()
if cert := v.Set.Finalize(height, round, m.HeadID, precommits); cert != nil {
v.finalize(cert)
}
}
func (v *Validator) castPrecommit(h, r uint64, head [32]byte) {
v.mu.Lock()
key := roundKey{h, r}
if v.precommitted[key] != [32]byte{} {
v.mu.Unlock()
return
}
v.precommitted[key] = head
m := &Message{Phase: PhasePrecommit, Height: h, Round: r, HeadID: head}
signed, err := m.Sign(v.Key)
v.mu.Unlock()
if err != nil {
return
}
v.OnVote(signed)
v.Broadcast("vote", signed)
}
// finalize installs a certificate and moves to the next height. The head ID
// becomes the anchor the following height must chain onto.
func (v *Validator) finalize(cert *Certificate) {
v.mu.Lock()
defer v.mu.Unlock()
if cert.Height != v.height {
return // stale or future finality
}
if _, seen := v.certs[cert.Height]; seen {
return // already finalized this height; conflicting certs impossible
}
var id [32]byte
idb, _ := hex.DecodeString(cert.HeadID)
copy(id[:], idb)
v.certs[cert.Height] = cert
v.lastHead = id
v.height = cert.Height + 1
}