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

105 lines
3.3 KiB
Go

package server
// Gossip: relays and light nodes exchange signed heads so that a split view
// (one mirror quietly serving a different object set) becomes detectable by
// comparing what independent peers claim for the same relay key. Reception
// is passive pull-free v1: peers POST heads here; the active polling side is
// the light node (internal/lightnode).
//
// Trust rules are deliberately narrow:
// - A head is stored only if its signature verifies under the public key
// that claims to have produced it, and its ID matches its bytes.
// - Keys are pinned on first contact (TOFU) per peer key, not per IP: the
// unit of trust is the relay identity, not the network path.
// - Two heads from one key at one epoch with different IDs mean the
// operator of that key is equivocating; the conflict is recorded and
// exposed rather than silently resolved.
import (
"crypto/ed25519"
"encoding/hex"
"encoding/json"
"net/http"
"sync"
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
)
type peerHead struct {
Head signedHead `json:"head"`
PublicKey string `json:"public_key"`
}
type gossipEntry struct {
head peerHead
epoch uint64
id [32]byte
}
type gossipState struct {
mu sync.Mutex
peers map[string]*gossipEntry // relay pubkey hex -> latest observed head
}
func newGossipState() *gossipState {
return &gossipState{peers: make(map[string]*gossipEntry)}
}
func (s *Server) handleGossipCheckpoint(w http.ResponseWriter, r *http.Request) {
var in peerHead
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192)).Decode(&in); err != nil {
writeErr(w, http.StatusBadRequest, "bad gossip body")
return
}
pub, err := hex.DecodeString(in.PublicKey)
if err != nil || len(pub) != ed25519.PublicKeySize {
writeErr(w, http.StatusBadRequest, "bad public_key")
return
}
cp, err := checkpoint.Decode(in.Head.Bytes)
if err != nil {
writeErr(w, http.StatusUnprocessableEntity, "bad checkpoint bytes")
return
}
if err := checkpoint.Verify(ed25519.PublicKey(pub), in.Head.Bytes, in.Head.Signature); err != nil {
s.metrics.inc(&s.metrics.gossipRejected)
writeErr(w, http.StatusUnauthorized, "signature fails")
return
}
idSum := checkpoint.ID(in.Head.Bytes)
var claimedID [32]byte
if got, err := hex.DecodeString(in.Head.ID); err != nil || len(got) != 32 {
writeErr(w, http.StatusBadRequest, "bad id")
return
} else {
copy(claimedID[:], got)
}
if claimedID != idSum {
writeErr(w, http.StatusUnprocessableEntity, "id does not match bytes")
return
}
keyHex := in.PublicKey
s.gossip.mu.Lock()
prev, seen := s.gossip.peers[keyHex]
switch {
case !seen || cp.Epoch > prev.epoch:
s.gossip.peers[keyHex] = &gossipEntry{head: in, epoch: cp.Epoch, id: idSum}
case cp.Epoch == prev.epoch && idSum != prev.id:
// Same key, same epoch, different bytes: equivocation.
s.metrics.inc(&s.metrics.gossipDivergence)
}
s.gossip.mu.Unlock()
s.metrics.inc(&s.metrics.gossipAccepted)
writeJSON(w, http.StatusOK, map[string]string{"status": "stored"})
}
func (s *Server) handlePeerHeads(w http.ResponseWriter, r *http.Request) {
s.gossip.mu.Lock()
defer s.gossip.mu.Unlock()
out := make([]peerHead, 0, len(s.gossip.peers))
for _, e := range s.gossip.peers {
out = append(out, e.head)
}
writeJSON(w, http.StatusOK, map[string]any{"heads": out})
}