- 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.
358 lines
8.8 KiB
Go
358 lines
8.8 KiB
Go
package lightnode
|
|
|
|
// WebSocket mirroring. The node exposes the relay's streaming protocol to
|
|
// its own clients and feeds it from upstream relays:
|
|
//
|
|
// client ──ws──▶ light node ──ws──▶ pinned relays
|
|
//
|
|
// Every envelope received upstream is signature-checked before forwarding
|
|
// and deduplicated across peers by content ID. A freshly streamed object is
|
|
// usually younger than the newest quorum-agreed checkpoint, so it cannot yet
|
|
// carry an inclusion proof; such objects are forwarded marked
|
|
// "verified": false and queued. The queue is re-proven against each new
|
|
// agreed root purely as bookkeeping (and dropped once covered or exhausted):
|
|
// clients that need proof-backed bytes should re-fetch /v1/objects/{id},
|
|
// which only ever serves proof-verified content. Streaming is news,
|
|
// fetching is evidence.
|
|
//
|
|
// Subscriptions demanded downstream are reconciled onto upstream
|
|
// connections when they are (re)established; a subscription added while a
|
|
// connection is already up takes effect at its next reconnect. Documented
|
|
// v1 simplification.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/coder/websocket"
|
|
|
|
"git.n1ko.dev/Niko/niko_trust/internal/smt"
|
|
"git.n1ko.dev/Niko/niko_trust/internal/transport"
|
|
)
|
|
|
|
const (
|
|
lnSendBuffer = 64
|
|
lnMaxConns = 64
|
|
lnMaxSubsPerConn = 16
|
|
lnPendingCap = 512
|
|
lnSeenCap = 10000
|
|
)
|
|
|
|
type lnSub struct {
|
|
channel, key string
|
|
}
|
|
|
|
type lnClient struct {
|
|
subs []lnSub
|
|
send chan []byte
|
|
done chan struct{}
|
|
evicted bool
|
|
}
|
|
|
|
type lnHub struct {
|
|
mu sync.Mutex
|
|
clients map[*lnClient]struct{}
|
|
demand map[lnSub]int // refcounted subscriptions demanded downstream
|
|
kick chan struct{} // wakes the manager so it can dial missing peers
|
|
}
|
|
|
|
func newLnHub() *lnHub {
|
|
return &lnHub{
|
|
clients: make(map[*lnClient]struct{}),
|
|
demand: make(map[lnSub]int),
|
|
kick: make(chan struct{}, 1),
|
|
}
|
|
}
|
|
|
|
// evict removes a client and drops its demand contributions.
|
|
func (h *lnHub) evict(c *lnClient) {
|
|
h.mu.Lock()
|
|
if !c.evicted {
|
|
c.evicted = true
|
|
close(c.done)
|
|
}
|
|
delete(h.clients, c)
|
|
for _, s := range c.subs {
|
|
k := lnSub{s.channel, s.key}
|
|
h.demand[k]--
|
|
if h.demand[k] <= 0 {
|
|
delete(h.demand, k)
|
|
}
|
|
}
|
|
h.mu.Unlock()
|
|
h.signal()
|
|
}
|
|
|
|
func (h *lnHub) signal() {
|
|
select {
|
|
case h.kick <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
|
|
type pendingEvent struct {
|
|
channel, key, objID string
|
|
attempts int
|
|
}
|
|
|
|
type wsNode struct {
|
|
node *Node
|
|
auth *upstreamAuth
|
|
|
|
hub *lnHub
|
|
|
|
mu sync.Mutex
|
|
seen map[string]struct{}
|
|
pending []pendingEvent
|
|
upstreamLive map[string]bool
|
|
upstreamStarting map[string]bool
|
|
}
|
|
|
|
func newWSNode(n *Node) (*wsNode, error) {
|
|
auth, err := newUpstreamAuth(n.cfg.CacheDir, n.http)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &wsNode{
|
|
node: n,
|
|
auth: auth,
|
|
hub: newLnHub(),
|
|
seen: make(map[string]struct{}),
|
|
upstreamLive: make(map[string]bool),
|
|
upstreamStarting: make(map[string]bool),
|
|
}, nil
|
|
}
|
|
|
|
// RunWS maintains one upstream stream per peer while subscriptions are
|
|
// demanded downstream. It blocks until ctx ends.
|
|
func (w *wsNode) RunWS(ctx context.Context) {
|
|
ticker := time.NewTicker(3 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-w.hub.kick:
|
|
case <-ticker.C:
|
|
}
|
|
w.flushPending()
|
|
w.connectAll(ctx)
|
|
}
|
|
}
|
|
|
|
// UpstreamReady reports whether at least one upstream stream is live. It is
|
|
// a convenience for operators and tests: events published before this point
|
|
// were never broadcast anywhere, so waiting on it avoids missing early ones.
|
|
func (w *wsNode) UpstreamReady() bool {
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
return len(w.upstreamLive) > 0
|
|
}
|
|
|
|
func (w *wsNode) hasDemand() bool {
|
|
w.hub.mu.Lock()
|
|
defer w.hub.mu.Unlock()
|
|
return len(w.hub.demand) > 0
|
|
}
|
|
|
|
func (w *wsNode) connectAll(ctx context.Context) {
|
|
if !w.hasDemand() {
|
|
return
|
|
}
|
|
for _, peer := range w.node.cfg.Peers {
|
|
w.mu.Lock()
|
|
live := w.upstreamLive[peer] || w.upstreamStarting[peer]
|
|
if !live {
|
|
w.upstreamStarting[peer] = true
|
|
}
|
|
w.mu.Unlock()
|
|
if live {
|
|
continue
|
|
}
|
|
|
|
go func(peer string) {
|
|
defer func() {
|
|
w.mu.Lock()
|
|
delete(w.upstreamStarting, peer)
|
|
delete(w.upstreamLive, peer)
|
|
w.mu.Unlock()
|
|
}()
|
|
w.serveUpstream(ctx, peer)
|
|
}(peer)
|
|
}
|
|
}
|
|
|
|
// serveUpstream runs one connection lifecycle: authenticate over HTTP,
|
|
// upgrade with the token, subscribe to everything currently demanded, pump
|
|
// events until the connection or ctx dies. Reconnection happens on the next
|
|
// manager tick because demand persists.
|
|
func (w *wsNode) serveUpstream(ctx context.Context, peer string) {
|
|
token, err := w.auth.session(ctx, peer)
|
|
if err != nil {
|
|
return
|
|
}
|
|
wsURL := strings.Replace(peer, "http", "ws", 1) + "/v1/ws?token=" + token
|
|
conn, _, err := websocket.Dial(ctx, wsURL, nil)
|
|
if err != nil {
|
|
w.auth.invalidate(peer)
|
|
return
|
|
}
|
|
defer conn.Close(websocket.StatusNormalClosure, "")
|
|
|
|
w.hub.mu.Lock()
|
|
for k := range w.hub.demand {
|
|
raw, _ := json.Marshal(map[string]string{"op": "subscribe", "channel": k.channel, "key": k.key})
|
|
if err := conn.Write(ctx, websocket.MessageText, raw); err != nil {
|
|
w.hub.mu.Unlock()
|
|
return
|
|
}
|
|
}
|
|
w.hub.mu.Unlock()
|
|
|
|
w.mu.Lock()
|
|
w.upstreamLive[peer] = true
|
|
w.mu.Unlock()
|
|
|
|
for {
|
|
typ, raw, err := conn.Read(ctx)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if typ != websocket.MessageText {
|
|
continue
|
|
}
|
|
w.handleUpstreamEvent(raw)
|
|
}
|
|
}
|
|
|
|
func handleUpstreamEvent(w *wsNode, raw []byte) { w.handleUpstreamEvent(raw) }
|
|
|
|
func (w *wsNode) handleUpstreamEvent(raw []byte) {
|
|
var ev struct {
|
|
Event string `json:"event"`
|
|
Channel string `json:"channel"`
|
|
Key string `json:"key"`
|
|
ObjectID string `json:"object_id"`
|
|
Envelope *struct {
|
|
TCE []byte `json:"tce"`
|
|
Signature []byte `json:"signature"`
|
|
} `json:"envelope"`
|
|
}
|
|
if json.Unmarshal(raw, &ev) != nil || ev.Event != "object" || ev.Envelope == nil {
|
|
return
|
|
}
|
|
objID := ev.ObjectID
|
|
if len(objID) != 64 || ev.Channel == "" || ev.Key == "" {
|
|
return
|
|
}
|
|
|
|
w.mu.Lock()
|
|
if _, dup := w.seen[objID]; dup {
|
|
w.mu.Unlock()
|
|
return // another peer already delivered this exact object
|
|
}
|
|
w.seen[objID] = struct{}{}
|
|
if len(w.seen) > lnSeenCap {
|
|
w.seen = make(map[string]struct{}) // coarse reset; dedupe is best-effort
|
|
}
|
|
w.mu.Unlock()
|
|
|
|
env := &transport.Envelope{TCE: ev.Envelope.TCE, Signature: ev.Envelope.Signature}
|
|
typName, err := env.Verify()
|
|
if err != nil || !validLNChannel(typName) {
|
|
return // never forward an envelope that does not verify locally
|
|
}
|
|
w.dispatch(ev.Channel, ev.Key, objID, ev.Envelope.TCE, ev.Envelope.Signature)
|
|
}
|
|
|
|
func validLNChannel(typeName string) bool {
|
|
switch typeName {
|
|
case "claim", "request", "response", "revocation":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// dispatch pushes to matching downstream clients and queues the object id
|
|
// for proof bookkeeping against the next quorum-agreed root.
|
|
func (w *wsNode) dispatch(channel, key, objID string, tceBytes, sig []byte) {
|
|
payload, _ := json.Marshal(map[string]any{
|
|
"event": "object",
|
|
"channel": channel,
|
|
"key": key,
|
|
"object_id": objID,
|
|
"envelope": map[string]any{"tce": tceBytes, "signature": sig},
|
|
"verified": false,
|
|
})
|
|
|
|
w.hub.mu.Lock()
|
|
for c := range w.hub.clients {
|
|
match := false
|
|
for _, s := range c.subs {
|
|
if s.channel == channel && s.key == key {
|
|
match = true
|
|
break
|
|
}
|
|
}
|
|
if !match || c.evicted {
|
|
continue
|
|
}
|
|
select {
|
|
case c.send <- payload:
|
|
default:
|
|
// Slow client under lock: close-once signalling, no send/close race.
|
|
c.evicted = true
|
|
close(c.done)
|
|
delete(w.hub.clients, c)
|
|
}
|
|
}
|
|
w.hub.mu.Unlock()
|
|
|
|
w.mu.Lock()
|
|
if len(w.pending) < lnPendingCap {
|
|
w.pending = append(w.pending, pendingEvent{channel: channel, key: key, objID: objID})
|
|
}
|
|
w.mu.Unlock()
|
|
}
|
|
|
|
// flushPending re-proves queued object ids against the newest agreed root,
|
|
// keeping only ids that are still unproven and under the attempt budget.
|
|
func (w *wsNode) flushPending() {
|
|
root, ok := w.node.decisionRootOK()
|
|
if !ok {
|
|
return
|
|
}
|
|
w.mu.Lock()
|
|
defer w.mu.Unlock()
|
|
kept := w.pending[:0]
|
|
for _, p := range w.pending {
|
|
idb, err := hex.DecodeString(p.objID)
|
|
var id [32]byte
|
|
if err == nil && len(idb) == 32 {
|
|
copy(id[:], idb)
|
|
if proof, err := w.node.inclusionProofFromPeer(p.objID); err == nil &&
|
|
smt.VerifyInclusion(root, id, proof) {
|
|
continue // now covered by the quorum-agreed checkpoint
|
|
}
|
|
}
|
|
p.attempts++
|
|
if p.attempts < 3 {
|
|
kept = append(kept, p)
|
|
}
|
|
}
|
|
w.pending = kept
|
|
}
|
|
|
|
// websocketAccept upgrades with permissive origins: the light node is a
|
|
// local mirror whose authority comes from its pinned peers, not from the
|
|
// browser that talks to it.
|
|
func websocketAccept(w http.ResponseWriter, r *http.Request) (*websocket.Conn, error) {
|
|
return websocket.Accept(w, r, &websocket.AcceptOptions{OriginPatterns: []string{"*"}})
|
|
}
|
|
|
|
var _ = http.StatusText
|