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

216 lines
5.6 KiB
Go

package server
// BFT validator mode. Opt-in: a relay that runs with -bft-validators also
// acts as a finality validator using its existing transport key. Non-validator
// relays and light nodes stay pure gossip participants and can still verify
// any published certificate offline against the public validator set.
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"sync"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/bft"
)
func bytesReader(raw []byte) *bytes.Reader { return bytes.NewReader(raw) }
// BFTConfig configures validator participation.
type BFTConfig struct {
// ValidatorKeys are hex Ed25519 public keys of every validator,
// index-aligned with ValidatorURLs.
ValidatorKeys []string
// ValidatorURLs are base URLs used to reach each validator.
ValidatorURLs []string
// RoundTimeout drives proposer rotation when a round stalls.
RoundTimeout time.Duration
}
type bftState struct {
set *bft.Set
me int // -1 when this relay is not a validator
val *bft.Validator
key ed25519.PrivateKey
urls []string
timeout time.Duration
client *http.Client
lastProg time.Time
mu sync.Mutex
}
// WithBFT enables validator participation. The relay's transport key doubles
// as its validator key: one key, one role, no new secrets.
func WithBFT(cfg BFTConfig) Option {
return func(s *Server) {
if s.ckpt == nil || len(cfg.ValidatorKeys) == 0 {
return // finality needs checkpoints to exist at all
}
set, err := bft.NewSet(cfg.ValidatorKeys, cfg.ValidatorURLs)
if err != nil {
return
}
st := &bftState{
set: set,
me: -1,
urls: cfg.ValidatorURLs,
timeout: cfg.RoundTimeout,
client: &http.Client{Timeout: 5 * time.Second},
}
myPub := s.ckpt.key.Public().(ed25519.PublicKey)
for i := range set.PubKeys {
if string(set.PubKeys[i]) == string(myPub) {
st.me = i
break
}
}
if st.me >= 0 {
st.key = s.ckpt.key
st.val = newMachineFor(s, st)
}
s.bft = st
}
}
// newMachineFor builds the state machine wired to this relay's heads and
// HTTP fan-out.
func newMachineFor(s *Server, st *bftState) *bft.Validator {
latest := func() ([32]byte, bool) {
h, ok := s.ckpt.latestHead()
if !ok {
return [32]byte{}, false
}
idb, err := hex.DecodeString(h.ID)
if err != nil || len(idb) != 32 {
return [32]byte{}, false
}
var id [32]byte
copy(id[:], idb)
return id, true
}
broadcast := func(path string, payload any) {
for _, u := range st.urls {
go func(url string) {
raw, _ := json.Marshal(payload)
req, err := http.NewRequest(http.MethodPost, url+"/v1/bft/"+path, bytesReader(raw))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := st.client.Do(req)
if err == nil {
resp.Body.Close()
}
}(u)
}
}
return bft.NewValidator(st.set, st.me, st.key, latest, broadcast)
}
// StartBFT runs the round driver until ctx ends.
func (s *Server) StartBFT(ctx context.Context) {
if s.bft == nil || s.bft.val == nil {
return
}
timeout := s.bft.timeout
if timeout <= 0 {
timeout = 2 * time.Second
}
ticker := time.NewTicker(timeout / 2)
defer ticker.Stop()
s.bft.lastProg = time.Now()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.bft.mu.Lock()
h := s.bft.val.Height()
progressed := time.Since(s.bft.lastProg) < timeout
r := uint64(0)
if !progressed {
r = (uint64(time.Now().UnixNano()) % uint64(len(s.bft.set.PubKeys)))
s.bft.lastProg = time.Now()
}
s.bft.mu.Unlock()
if !progressed {
s.bft.val.StartRound(h, r%uint64(len(s.bft.set.PubKeys)))
} else {
s.bft.val.StartRound(h, 0)
}
}
}
}
// ------------------------------------------------------------------ handlers
func (s *Server) handleBFTProposal(w http.ResponseWriter, r *http.Request) {
var signed bft.Signed
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&signed); err != nil {
writeErr(w, http.StatusBadRequest, "bad proposal body")
return
}
if s.bft == nil || s.bft.val == nil {
writeErr(w, http.StatusNotImplemented, "bft disabled")
return
}
s.bft.val.OnProposal(&signed)
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleBFTVote(w http.ResponseWriter, r *http.Request) {
var signed bft.Signed
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&signed); err != nil {
writeErr(w, http.StatusBadRequest, "bad vote body")
return
}
if s.bft == nil || s.bft.val == nil {
writeErr(w, http.StatusNotImplemented, "bft disabled")
return
}
s.bft.val.OnVote(&signed)
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleBFTState(w http.ResponseWriter, r *http.Request) {
if s.bft == nil {
writeErr(w, http.StatusNotImplemented, "bft disabled")
return
}
out := map[string]any{"enabled": s.bft.me >= 0}
if s.bft.val != nil {
out["height"] = s.bft.val.Height()
lf := s.bft.val.LastFinalized()
out["last_finalized"] = hex.EncodeToString(lf[:])
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleBFTCertificate(w http.ResponseWriter, r *http.Request) {
if s.bft == nil || s.bft.val == nil {
writeErr(w, http.StatusNotImplemented, "bft disabled")
return
}
h, err := strconv.ParseUint(r.PathValue("height"), 10, 64)
if err != nil {
writeErr(w, http.StatusBadRequest, "bad height")
return
}
cert := s.bft.val.Certificate(h)
if cert == nil {
writeErr(w, http.StatusNotFound, "no certificate")
return
}
writeJSON(w, http.StatusOK, cert)
}
// SetBFT applies BFT configuration after construction (used by main before
// serving starts).
func (s *Server) SetBFT(cfg BFTConfig) {
WithBFT(cfg)(s)
}