- 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.
105 lines
3.3 KiB
Go
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})
|
|
}
|