- 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.
264 lines
6.6 KiB
Go
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
|
|
}
|