- 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.
216 lines
5.6 KiB
Go
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)
|
|
}
|