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

262 lines
6.6 KiB
Go

// Package lightnode implements a verifying mirror of the relay API that
// stores no history: it tracks signed checkpoints from a small set of peers,
// demands a quorum agree on the committed root, then serves objects fetched
// on demand — every object checked against an inclusion proof before it is
// cached or returned. Storage cost is the working set plus ~100 bytes of
// heads, which is what makes a phone-sized node possible.
//
// The light node never evaluates trust: it serves exactly the envelopes a
// full relay would, after proving they belong to the agreed set. Consumers
// keep using verify.Graph locally (INV-5 preserved end to end).
package lightnode
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"sort"
"sync"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/checkpoint"
)
// Config configures a light node.
type Config struct {
// Peers are relay base URLs, e.g. https://trust.n1ko.dev.
Peers []string
// PinKeys are hex Ed25519 relay public keys to trust. Empty enables
// trust-on-first-use: the first key seen for a peer is recorded.
PinKeys []string
// Quorum is how many distinct relay keys must agree on one root for the
// node to accept it. Zero means all peers.
Quorum int
// CacheDir stores fetched envelopes; empty disables persistence.
CacheDir string
// CacheMaxBytes caps the cache directory; oldest files evicted first.
CacheMaxBytes int64
// BFTValidators are hex public keys of the validator set. When set, the
// node exposes GET /v1/bft/certificate/{height} and verifies every
// certificate against exactly these keys before serving it.
BFTValidators []string
}
var (
// ErrNoQuorum means fewer agreeing keys than configured.
ErrNoQuorum = errors.New("lightnode: no quorum")
// ErrUnpinnedKey means TOFU is off and an unknown relay key answered.
ErrUnpinnedKey = errors.New("lightnode: unpinned relay key")
)
// Head is one verified checkpoint observation from one relay key.
type Head struct {
PublicKey string
ID string
Epoch uint64
Size uint64
Root [32]byte
Bytes []byte
Signature []byte
}
// Node is the light node core. Safe for concurrent use.
type Node struct {
cfg Config
http *http.Client
mu sync.Mutex
pins map[string]bool
heads map[string]*Head // relay pubkey -> newest verified head
decision *Head // quorum-approved head; nil until first success
lastErr error
wsOnce sync.Once
ws *wsNode
wsErr error
}
// WS returns the streaming subsystem, building it on first use.
func (n *Node) WS() (*wsNode, error) {
n.wsOnce.Do(func() {
n.ws, n.wsErr = newWSNode(n)
})
return n.ws, n.wsErr
}
// New constructs a node.
func New(cfg Config) *Node {
if cfg.Quorum <= 0 {
cfg.Quorum = len(cfg.Peers)
}
if cfg.CacheMaxBytes == 0 {
cfg.CacheMaxBytes = 256 << 20 // 256 MiB default working set
}
n := &Node{
cfg: cfg,
http: &http.Client{Timeout: 10 * time.Second},
pins: make(map[string]bool),
heads: make(map[string]*Head),
}
for _, k := range cfg.PinKeys {
n.pins[k] = true
}
return n
}
type wireHead struct {
Bytes string `json:"bytes"`
Signature string `json:"signature"`
ID string `json:"id"`
PublicKey string `json:"public_key"`
Checkpoint struct {
Epoch uint64 `json:"epoch"`
Size uint64 `json:"size"`
Root string `json:"root"`
} `json:"checkpoint"`
}
func (n *Node) fetchHead(ctx context.Context, peer string) (*Head, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, peer+"/v1/checkpoint/latest", nil)
if err != nil {
return nil, err
}
resp, err := n.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("lightnode: peer status " + resp.Status)
}
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
if err != nil {
return nil, err
}
var w wireHead
if json.Unmarshal(raw, &w) != nil {
return nil, errors.New("lightnode: bad head json")
}
b, err := base64.StdEncoding.DecodeString(w.Bytes)
if err != nil {
return nil, errors.New("lightnode: bad head bytes")
}
cp, err := checkpoint.Decode(b)
if err != nil {
return nil, err
}
sig, err := base64.StdEncoding.DecodeString(w.Signature)
if err != nil {
return nil, errors.New("lightnode: bad signature encoding")
}
pub, err := hex.DecodeString(w.PublicKey)
if err != nil || len(pub) != ed25519.PublicKeySize {
return nil, ErrUnpinnedKey
}
if err := checkpoint.Verify(ed25519.PublicKey(pub), b, sig); err != nil {
return nil, err
}
var root [32]byte
rb, _ := hex.DecodeString(w.Checkpoint.Root)
copy(root[:], rb)
idSum := checkpoint.ID(b)
if len(w.ID) == 64 && hex.EncodeToString(idSum[:]) != w.ID {
return nil, errors.New("lightnode: head id mismatch")
}
return &Head{
PublicKey: w.PublicKey,
ID: w.ID,
Epoch: cp.Epoch,
Size: cp.Size,
Root: root,
Bytes: b,
Signature: sig,
}, nil
}
// Refresh polls every peer concurrently, records verified observations and
// recomputes the decision.
func (n *Node) Refresh(ctx context.Context) error {
type result struct {
head *Head
err error
}
ch := make(chan result, len(n.cfg.Peers))
for _, p := range n.cfg.Peers {
go func(peer string) {
h, err := n.fetchHead(ctx, peer)
ch <- result{h, err}
}(p)
}
n.mu.Lock()
defer n.mu.Unlock()
var firstErr error
for range n.cfg.Peers {
res := <-ch
if res.err != nil {
if firstErr == nil {
firstErr = res.err
}
continue
}
h := res.head
if !n.pins[h.PublicKey] {
if len(n.cfg.PinKeys) > 0 {
if firstErr == nil {
firstErr = ErrUnpinnedKey
}
continue
}
n.pins[h.PublicKey] = true // TOFU
}
if old, ok := n.heads[h.PublicKey]; !ok || h.Epoch >= old.Epoch {
n.heads[h.PublicKey] = h
}
}
err := n.redecideLocked()
if firstErr == nil {
firstErr = err
}
n.lastErr = firstErr
return firstErr
}
// redecideLocked requires the configured number of distinct relay keys to
// agree on one root and picks the highest-epoch observation among them.
func (n *Node) redecideLocked() error {
byRoot := make(map[[32]byte][]*Head)
for _, h := range n.heads {
byRoot[h.Root] = append(byRoot[h.Root], h)
}
var best []*Head
for _, group := range byRoot {
if len(group) > len(best) {
best = group
}
}
if len(best) < n.cfg.Quorum {
n.decision = nil
return ErrNoQuorum
}
sort.Slice(best, func(i, j int) bool { return best[i].Epoch > best[j].Epoch })
n.decision = best[0]
return nil
}
// Decision returns the currently accepted head, if any.
func (n *Node) Decision() *Head {
n.mu.Lock()
defer n.mu.Unlock()
return n.decision
}