- 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.
262 lines
6.6 KiB
Go
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
|
|
}
|