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

264 lines
6.6 KiB
Go

package bft_test
import (
"crypto/ed25519"
"encoding/hex"
"sync"
"testing"
"git.n1ko.dev/Niko/niko_trust/internal/bft"
)
// bus wires validators over an in-memory network. Delivery is synchronous;
// messages to offline members are dropped exactly like an unreachable peer.
type bus struct {
mu sync.Mutex
vals []*bft.Validator
online []bool
}
func (b *bus) deliver(from int) func(string, any) {
return func(path string, payload any) {
signed, ok := payload.(*bft.Signed)
if !ok {
return
}
// Snapshot targets under the lock, deliver outside it: handlers
// broadcast recursively, and the bus lock is not reentrant.
b.mu.Lock()
var targets []*bft.Validator
for i := range b.vals {
if i != from && b.online[i] {
targets = append(targets, b.vals[i])
}
}
b.mu.Unlock()
for _, v := range targets {
switch path {
case "proposal":
v.OnProposal(signed)
case "vote":
v.OnVote(signed)
}
}
}
}
// mesh builds n validators sharing one candidate head, some possibly offline.
func newMesh(t *testing.T, n int, online []bool, headID [32]byte) (*bus, []*bft.Validator) {
t.Helper()
keys := make([]ed25519.PrivateKey, n)
hexKeys := make([]string, n)
urls := make([]string, n)
for i := 0; i < n; i++ {
_, priv, err := ed25519.GenerateKey(nil)
if err != nil {
t.Fatal(err)
}
keys[i] = priv
hexKeys[i] = hex.EncodeToString(priv.Public().(ed25519.PublicKey))
urls[i] = string(rune('a' + i))
}
set, err := bft.NewSet(hexKeys, urls)
if err != nil {
t.Fatal(err)
}
b := &bus{online: online}
for i := 0; i < n; i++ {
i := i
v := bft.NewValidator(set, i, keys[i],
func() ([32]byte, bool) { return headID, true },
b.deliver(i))
b.vals = append(b.vals, v)
}
return b, b.vals
}
// startWhenProposerOnline drives rounds r=0..15 until the deterministic
// proposer belongs to the online set, then runs that round.
func startWhenProposerOnline(t *testing.T, vals []*bft.Validator, online []bool, h uint64) {
t.Helper()
for r := uint64(0); r < 16; r++ {
pi := proposerIndex(t, vals, h, r)
if pi < len(online) && online[pi] {
vals[pi].StartRound(h, r)
return
}
}
t.Fatal("no online proposer within 16 rounds")
}
func proposerIndex(t *testing.T, vals []*bft.Validator, h, r uint64) int {
t.Helper()
want := vals[0].Set.Proposer(h, r)
for i, v := range vals {
pub := hex.EncodeToString(v.Set.PubKeys[i])
if pub == hex.EncodeToString(want) {
return i
}
}
t.Fatal("proposer not found")
return -1
}
func TestFinalizesWithOneValidatorOffline(t *testing.T) {
common := testHeadByte(7)
online := []bool{true, true, true, false}
_, vals := newMesh(t, 4, online, common)
startWhenProposerOnline(t, vals, online, 1)
for i, v := range vals {
if !online[i] {
continue
}
if v.Height() != 2 {
t.Fatalf("validator %d stuck at height %d", i, v.Height())
}
cert := v.Certificate(1)
if cert == nil || cert.HeadID != hex.EncodeToString(common[:]) {
t.Fatalf("validator %d missing certificate %+v", i, cert)
}
// The certificate verifies offline against the full validator set.
fullSet, err := bft.NewSet(pubHexes(vals), urlsOf(4))
if err != nil {
t.Fatal(err)
}
if err := fullSet.VerifyCertificate(cert); err != nil {
t.Fatalf("certificate fails offline verification: %v", err)
}
}
// The offline validator never advanced.
if vals[3].Height() != 1 {
t.Fatal("offline validator unexpectedly advanced")
}
}
func TestSplitVoteNeverFinalizesTwoHeads(t *testing.T) {
headA := testHeadByte(1)
headB := testHeadByte(2)
keys, hexKeys := genKeys(4)
urls := urlsOf(4)
set, err := bft.NewSet(hexKeys, urls)
if err != nil {
t.Fatal(err)
}
var mu sync.Mutex
deliveries := map[int][]struct {
path string
p *bft.Signed
}{}
record := func(from int) func(string, any) {
return func(path string, payload any) {
mu.Lock()
defer mu.Unlock()
if s, ok := payload.(*bft.Signed); ok {
deliveries[from] = append(deliveries[from], struct {
path string
p *bft.Signed
}{path, s})
}
}
}
// Four validators, each seeing a DIFFERENT proposal: A,B see headA from
// the proposer; C,D see headB (equivocation by the proposer). No side
// reaches quorum, so nothing may finalize either head.
vals := make([]*bft.Validator, 4)
heads := [][32]byte{headA, headA, headB, headB}
for i := range vals {
i := i
vals[i] = bft.NewValidator(set, i, keys[i],
func() ([32]byte, bool) { return heads[i], true },
record(i))
}
pi := proposerIndex(t, vals, 1, 0)
pA, _ := bft.Propose(&bft.Proposal{Height: 1, Round: 0, HeadID: headA}, keys[pi])
pB, _ := bft.Propose(&bft.Proposal{Height: 1, Round: 0, HeadID: headB}, keys[pi])
vals[0].OnProposal(pA)
vals[1].OnProposal(pA)
vals[2].OnProposal(pB)
vals[3].OnProposal(pB)
for _, v := range vals {
if v.Height() != 1 {
t.Fatal("a height finalized despite an equivocating proposal")
}
if v.Certificate(1) != nil {
t.Fatal("certificate exists without quorum")
}
}
}
func TestCertificateTamperRejected(t *testing.T) {
common := testHeadByte(9)
online := []bool{true, true, true, false}
_, vals := newMesh(t, 4, online, common)
startWhenProposerOnline(t, vals, online, 1)
cert := vals[0].Certificate(1)
if cert == nil {
t.Fatal("no certificate produced")
}
fullSet, _ := bft.NewSet(pubHexes(vals), urlsOf(4))
// Flip one precommit signature byte.
bad := &bft.Certificate{Height: cert.Height, HeadID: cert.HeadID,
Precommits: append([]bft.Signed(nil), cert.Precommits...)}
bad.Precommits[0].Signature[0] ^= 0x01
if err := fullSet.VerifyCertificate(bad); err == nil {
t.Fatal("tampered certificate accepted")
}
// Drop votes below quorum.
short := &bft.Certificate{Height: cert.Height, HeadID: cert.HeadID,
Precommits: cert.Precommits[:1]}
if len(short.Precommits) >= fullSet.Quorum() {
t.Skip("quorum of 4-node set fits in one vote")
}
if err := fullSet.VerifyCertificate(short); err == nil {
t.Fatal("sub-quorum certificate accepted")
}
}
func genKeys(n int) ([]ed25519.PrivateKey, []string) {
keys := make([]ed25519.PrivateKey, n)
hexKeys := make([]string, n)
for i := 0; i < n; i++ {
_, priv, _ := ed25519.GenerateKey(nil)
keys[i] = priv
hexKeys[i] = hex.EncodeToString(priv.Public().(ed25519.PublicKey))
}
return keys, hexKeys
}
func urlsOf(n int) []string {
out := make([]string, n)
for i := range out {
out[i] = string(rune('a' + i))
}
return out
}
func pubHexes(vals []*bft.Validator) []string {
out := make([]string, len(vals))
for i, v := range vals {
out[i] = hex.EncodeToString(v.Set.PubKeys[i])
}
return out
}
func testHeadByte(seed byte) [32]byte {
var h [32]byte
for i := range h {
h[i] = seed
}
return h
}