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

463 lines
12 KiB
Go

package lightnode
import (
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"github.com/coder/websocket"
"git.n1ko.dev/Niko/niko_trust/internal/bft"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/smt"
)
// errNoDecision is returned until the first quorum-approved head exists.
var errNoDecision = errors.New("lightnode: no agreed checkpoint yet")
func (n *Node) pickPeer() string {
n.mu.Lock()
defer n.mu.Unlock()
for _, p := range n.cfg.Peers {
return p
}
return ""
}
// objectFromCache returns a cached envelope body, if present.
func (n *Node) objectFromCache(id string) ([]byte, bool) {
if n.cfg.CacheDir == "" {
return nil, false
}
raw, err := os.ReadFile(filepath.Join(n.cfg.CacheDir, "objects", id+".json"))
return raw, err == nil
}
func (n *Node) cacheObject(id string, body []byte) {
if n.cfg.CacheDir == "" {
return
}
dir := filepath.Join(n.cfg.CacheDir, "objects")
if os.MkdirAll(dir, 0o700) == nil {
_ = os.WriteFile(filepath.Join(dir, id+".json"), body, 0o600)
}
n.evictLocked()
}
// evictLocked drops oldest cached objects while over budget.
func (n *Node) evictLocked() {
dir := filepath.Join(n.cfg.CacheDir, "objects")
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type item struct {
name string
size int64
mod int64
}
var items []item
var total int64
for _, e := range entries {
fi, err := e.Info()
if err != nil {
continue
}
items = append(items, item{e.Name(), fi.Size(), fi.ModTime().UnixNano()})
total += fi.Size()
}
if total <= n.cfg.CacheMaxBytes {
return
}
sort.Slice(items, func(i, j int) bool { return items[i].mod < items[j].mod })
for _, it := range items {
if total <= n.cfg.CacheMaxBytes {
break
}
if os.Remove(filepath.Join(dir, it.name)) == nil {
total -= it.size
}
}
}
// decisionRoot returns the root every served proof must match.
func (n *Node) decisionRoot() ([32]byte, error) {
n.mu.Lock()
defer n.mu.Unlock()
if n.decision == nil {
return [32]byte{}, errNoDecision
}
return n.decision.Root, nil
}
// decisionRootOK is decisionRoot with a boolean for callers that treat "no
// decision yet" as a plain no-op.
func (n *Node) decisionRootOK() ([32]byte, bool) {
r, err := n.decisionRoot()
return r, err == nil
}
// inclusionProofFromPeer fetches an inclusion proof from any peer. The
// caller verifies it against the root it trusts.
func (n *Node) inclusionProofFromPeer(idHex string) ([]byte, error) {
pr, err := n.verifiedProof(context.Background(), idHex, true)
if err != nil {
return nil, err
}
return base64.StdEncoding.DecodeString(pr.Proof)
}
type proofResponse struct {
Proof string `json:"proof"`
Root string `json:"root"`
}
// verifiedProof fetches an inclusion/absence proof from any peer and accepts
// it only if it verifies against the quorum-agreed root.
func (n *Node) verifiedProof(ctx context.Context, idHex string, wantPresent bool) (*proofResponse, error) {
root, err := n.decisionRoot()
if err != nil {
return nil, err
}
kind := "absent"
if wantPresent {
kind = "object"
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
n.pickPeer()+"/v1/proof/"+kind+"/"+idHex, 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: proof status " + resp.Status)
}
var pr proofResponse
if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&pr) != nil {
return nil, errors.New("lightnode: bad proof json")
}
proof, err := base64.StdEncoding.DecodeString(pr.Proof)
if err != nil {
return nil, err
}
var id [32]byte
idb, err := hex.DecodeString(idHex)
if err != nil || len(idb) != 32 {
return nil, errors.New("lightnode: bad id")
}
copy(id[:], idb)
if wantPresent && !smt.VerifyInclusion(root, id, proof) {
return nil, errors.New("lightnode: inclusion proof rejected against quorum root")
}
if !wantPresent && !smt.VerifyAbsence(root, id, proof) {
return nil, errors.New("lightnode: absence proof rejected against quorum root")
}
return &pr, nil
}
// Handler returns the relay-compatible read API plus node status.
func (n *Node) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /v1/objects/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if len(id) != 64 {
writeErr(w, http.StatusBadRequest, "bad object id")
return
}
if body, ok := n.objectFromCache(id); ok {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Cache", "hit")
w.Write(body)
return
}
pr, err := n.verifiedProof(r.Context(), id, true)
if err != nil {
writeErr(w, http.StatusConflict, err.Error())
return
}
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
n.pickPeer()+"/v1/objects/"+id, nil)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
resp, err := n.http.Do(req)
if err != nil {
writeErr(w, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
writeErr(w, resp.StatusCode, string(body))
return
}
n.cacheObject(id, body)
w.Header().Set("Content-Type", "application/json")
w.Header().Set("X-Proof-Root", pr.Root)
w.Write(body)
})
mux.HandleFunc("GET /v1/checkpoint/latest", func(w http.ResponseWriter, r *http.Request) {
n.mu.Lock()
d := n.decision
n.mu.Unlock()
if d == nil {
writeErr(w, http.StatusNotFound, "no agreed checkpoint yet")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"bytes": base64.StdEncoding.EncodeToString(d.Bytes),
"signature": base64.StdEncoding.EncodeToString(d.Signature),
"id": d.ID,
"public_key": d.PublicKey,
})
})
mux.HandleFunc("GET /v1/proof/object/{id}", func(w http.ResponseWriter, r *http.Request) {
serveVerifiedProof(n, w, r, true)
})
mux.HandleFunc("GET /v1/proof/absent/{id}", func(w http.ResponseWriter, r *http.Request) {
serveVerifiedProof(n, w, r, false)
})
mux.HandleFunc("GET /v1/config", func(w http.ResponseWriter, r *http.Request) {
keys := make([]string, 0, len(n.pins))
for k := range n.pins {
keys = append(keys, k)
}
sort.Strings(keys)
writeJSON(w, http.StatusOK, map[string]any{
"mode": "lightnode",
"peers": n.cfg.Peers,
"pinned_keys": keys,
"quorum": n.cfg.Quorum,
})
})
mux.HandleFunc("GET /v1/bft/certificate/{height}", func(w http.ResponseWriter, r *http.Request) {
if len(n.cfg.BFTValidators) == 0 {
writeErr(w, http.StatusNotImplemented, "no validator set pinned")
return
}
h := r.PathValue("height")
for _, peer := range n.cfg.Peers {
req, err := http.NewRequestWithContext(r.Context(), http.MethodGet,
strings.TrimSuffix(peer, "/")+"/v1/bft/certificate/"+h, nil)
if err != nil {
continue
}
resp, err := n.http.Do(req)
if err != nil {
continue
}
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
continue
}
var cert bft.Certificate
if json.Unmarshal(raw, &cert) != nil {
continue
}
set, err := bft.NewSet(n.cfg.BFTValidators, make([]string, len(n.cfg.BFTValidators)))
if err != nil || set.VerifyCertificate(&cert) != nil {
writeErr(w, http.StatusConflict, "certificate fails against pinned validators")
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(raw)
return
}
writeErr(w, http.StatusBadGateway, "no peer served a certificate")
})
mux.HandleFunc("GET /v1/ws", func(w http.ResponseWriter, r *http.Request) {
serveDownstreamWS(n, w, r)
})
mux.HandleFunc("GET /v1/healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
})
mux.HandleFunc("GET /v1/readyz", func(w http.ResponseWriter, r *http.Request) {
n.mu.Lock()
d := n.decision
n.mu.Unlock()
if d == nil {
writeErr(w, http.StatusServiceUnavailable, "no agreed checkpoint yet")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
})
return mux
}
func serveVerifiedProof(n *Node, w http.ResponseWriter, r *http.Request, wantPresent bool) {
id := r.PathValue("id")
if len(id) != 64 {
writeErr(w, http.StatusBadRequest, "bad object id")
return
}
pr, err := n.verifiedProof(r.Context(), id, wantPresent)
if err != nil {
status := http.StatusBadGateway
if errors.Is(err, errNoDecision) {
status = http.StatusServiceUnavailable
} else if strings.Contains(err.Error(), "rejected") {
status = http.StatusConflict
}
writeErr(w, status, err.Error())
return
}
writeJSON(w, http.StatusOK, pr)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
// Run refreshes on a ticker until ctx ends.
func (n *Node) Run(ctx context.Context, interval time.Duration) {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = n.Refresh(ctx)
}
}
}
// serveDownstreamWS upgrades a local client and mirrors the relay's frame
// protocol. No authentication: the light node is an operator-run local
// mirror, and its own credentials never delegate.
func serveDownstreamWS(n *Node, w http.ResponseWriter, r *http.Request) {
wsn, err := n.WS()
if err != nil {
writeErr(w, http.StatusInternalServerError, "ws unavailable")
return
}
conn, err := websocketAccept(w, r)
if err != nil {
return
}
defer conn.Close(websocket.StatusInternalError, "")
client := &lnClient{send: make(chan []byte, lnSendBuffer), done: make(chan struct{})}
wsn.hub.mu.Lock()
if len(wsn.hub.clients) >= lnMaxConns {
wsn.hub.mu.Unlock()
conn.Close(websocket.StatusPolicyViolation, "too many connections")
return
}
wsn.hub.clients[client] = struct{}{}
wsn.hub.mu.Unlock()
defer wsn.hub.evict(client)
done := make(chan struct{})
go func() {
defer close(done)
for {
select {
case <-r.Context().Done():
return
case <-client.done:
conn.Close(websocket.StatusPolicyViolation, "evicted")
return
case payload := <-client.send:
if err := conn.Write(r.Context(), websocket.MessageText, payload); err != nil {
return
}
}
}
}()
for {
typ, raw, err := conn.Read(r.Context())
if err != nil {
break
}
if typ != websocket.MessageText {
continue
}
var msg struct {
Op string `json:"op"`
Channel string `json:"channel"`
Key string `json:"key"`
}
if json.Unmarshal(raw, &msg) != nil || (msg.Op != "subscribe" && msg.Op != "unsubscribe") {
safeSend(conn, r, map[string]string{"event": "error", "message": "bad control frame"})
continue
}
if !lnChannelKnown(msg.Channel) {
safeSend(conn, r, map[string]string{"event": "error", "message": "unknown channel"})
continue
}
wsn.hub.mu.Lock()
switch msg.Op {
case "subscribe":
if msg.Key == "" || len(client.subs) >= lnMaxSubsPerConn {
wsn.hub.mu.Unlock()
safeSend(conn, r, map[string]string{"event": "error", "message": "bad key or too many subscriptions"})
continue
}
client.subs = append(client.subs, lnSub{msg.Channel, msg.Key})
wsn.hub.demand[lnSub{msg.Channel, msg.Key}]++
case "unsubscribe":
kept := client.subs[:0]
for _, x := range client.subs {
if x.channel == msg.Channel && (msg.Key == "" || x.key == msg.Key) {
k := lnSub{x.channel, x.key}
wsn.hub.demand[k]--
if wsn.hub.demand[k] <= 0 {
delete(wsn.hub.demand, k)
}
continue
}
kept = append(kept, x)
}
client.subs = kept
}
wsn.hub.mu.Unlock()
wsn.hub.signal()
safeSend(conn, r, map[string]string{"event": "subscribed", "channel": msg.Channel, "key": msg.Key})
}
<-done // let the writer drain before the deferred close kills it
}
func safeSend(conn *websocket.Conn, r *http.Request, v any) {
raw, _ := json.Marshal(v)
_ = conn.Write(r.Context(), websocket.MessageText, raw)
}
func lnChannelKnown(ch string) bool {
switch ch {
case "claims", "requests", "responses", "revocations":
return true
}
return false
}