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

366 lines
10 KiB
Go

package lightnode_test
import (
"bytes"
"context"
"encoding/base64"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/lightnode"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/server"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
// relayFixture is one full relay plus a helper to store objects into it.
type relayFixture struct {
ts *httptest.Server
srv *server.Server
}
func newRelay(t *testing.T, dir string) *relayFixture {
t.Helper()
srv := server.New("trust.n1ko.dev", dir,
server.WithCheckpoints(dir, server.CheckpointConfig{Interval: time.Hour, EveryN: 1}))
ctx, cancel := context.WithCancel(context.Background())
srv.StartCheckpoints(ctx)
t.Cleanup(cancel)
return &relayFixture{ts: httptest.NewServer(srv.Handler()), srv: srv}
}
func (f *relayFixture) putEnvelope(t *testing.T, b, sig []byte) string {
t.Helper()
resp, err := http.Post(f.ts.URL+"/v1/objects", "application/json",
bytes.NewReader(mustJSON(t, map[string]any{"tce": b64(b), "signature": b64(sig)})))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("put status %d: %s", resp.StatusCode, raw)
}
var out struct {
ObjectID string `json:"object_id"`
}
json.NewDecoder(resp.Body).Decode(&out)
return out.ObjectID
}
func b64(b []byte) string { return base64.StdEncoding.EncodeToString(b) }
func mustJSON(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
func TestLightnodeQuorumAndServing(t *testing.T) {
dir := t.TempDir()
a := newRelay(t, filepath.Join(dir, "a"))
c := newRelay(t, filepath.Join(dir, "c"))
// Both relays must hold the SAME object set: the root is a function of
// the set, so agreement is possible only over identical storage.
b, sig := makeTestClaim(t)
objA := a.putEnvelope(t, b, sig)
objC := c.putEnvelope(t, b, sig)
if objA != objC {
t.Fatal("same content produced different ids")
}
cfgA := getConfigPubkeys(t, a.ts.URL)
cfgC := getConfigPubkeys(t, c.ts.URL)
cacheDir := filepath.Join(dir, "cache")
node := lightnode.New(lightnode.Config{
Peers: []string{a.ts.URL, c.ts.URL},
PinKeys: []string{cfgA, cfgC},
Quorum: 2,
CacheDir: cacheDir,
CacheMaxBytes: 1 << 20,
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go node.Run(ctx, 50*time.Millisecond)
// Wait for the first agreed decision.
deadline := time.Now().Add(2 * time.Second)
for node.Decision() == nil {
if time.Now().After(deadline) {
t.Fatal("no quorum decision reached")
}
time.Sleep(10 * time.Millisecond)
}
// Serve an object through the light node and verify caching works even
// after both peers disappear.
lts := httptest.NewServer(node.Handler())
defer lts.Close()
fetch := func(url string) int {
resp, err := http.Get(url + "/v1/objects/" + objA)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body)
return resp.StatusCode
}
if code := fetch(lts.URL); code != http.StatusOK {
t.Fatalf("first fetch status %d", code)
}
// Absence of an unknown object must also verify against the root
// (needs a live peer for proof transport).
resp, err := http.Get(lts.URL + "/v1/proof/absent/" + hexOnes())
if err != nil {
t.Fatal(err)
}
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("verified absence status %d", resp.StatusCode)
}
a.ts.Close()
c.ts.Close()
// The fetched object survives peer loss from the local cache.
if code := fetch(lts.URL); code != http.StatusOK {
t.Fatalf("cached fetch after peers down: status %d", code)
}
if _, err := os.Stat(filepath.Join(cacheDir, "objects", objA+".json")); err != nil {
t.Fatal("object not cached to disk:", err)
}
}
func TestLightnodeRejectsSplitView(t *testing.T) {
dir := t.TempDir()
// Two relays with disjoint object sets commit to different roots.
x := newRelay(t, filepath.Join(dir, "x"))
y := newRelay(t, filepath.Join(dir, "y"))
bx, sx := makeTestClaim(t)
x.putEnvelope(t, bx, sx)
by, sy := makeTestClaim(t)
y.putEnvelope(t, by, sy)
node := lightnode.New(lightnode.Config{
Peers: []string{x.ts.URL, y.ts.URL},
PinKeys: []string{getConfigPubkeys(t, x.ts.URL), getConfigPubkeys(t, y.ts.URL)},
Quorum: 2,
})
if err := node.Refresh(context.Background()); err == nil {
t.Skip("sets happened to agree; rerun")
} else if node.Decision() != nil {
t.Fatal("decision made despite split view")
}
}
func TestLightnodeUnpinnedKeyRejectedWhenPinningEnabled(t *testing.T) {
dir := t.TempDir()
a := newRelay(t, filepath.Join(dir, "a"))
other := newRelay(t, filepath.Join(dir, "other"))
b1, s1 := makeTestClaim(t)
a.putEnvelope(t, b1, s1)
b2, s2 := makeTestClaim(t)
other.putEnvelope(t, b2, s2)
node := lightnode.New(lightnode.Config{
Peers: []string{a.ts.URL, other.ts.URL},
PinKeys: []string{getConfigPubkeys(t, a.ts.URL)}, // only A is trusted
})
err := node.Refresh(context.Background())
if err == nil && node.Decision() == nil {
t.Fatal("expected error or decision")
}
if err == nil {
// Refresh succeeded only because quorum defaults to len(peers)=2...
// With pinning on, the unpinned peer must not have contributed.
t.Log("refresh ok; verifying contribution count via pins")
if len(node.Decision().PublicKey) == 0 {
t.Fatal("empty decision")
}
} else if err != lightnode.ErrUnpinnedKey && err.Error() != lightnode.ErrNoQuorum.Error() {
t.Fatalf("unexpected error: %v", err)
}
}
func getConfigPubkeys(t *testing.T, url string) string {
t.Helper()
resp, err := http.Get(url + "/v1/config")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var cfg struct {
RelayPubkey string `json:"relay_pubkey"`
}
json.NewDecoder(resp.Body).Decode(&cfg)
if cfg.RelayPubkey == "" {
t.Fatal("peer did not expose relay_pubkey")
}
return cfg.RelayPubkey
}
func hexOnes() string {
b := make([]byte, 32)
for i := range b {
b[i] = 0xEE
}
return hex.EncodeToString(b)
}
func makeTestClaim(t *testing.T) ([]byte, []byte) {
t.Helper()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"light.test": tce.Bool(true)},
CreatedAt: uint64(time.Now().Unix()),
Serial: 1,
Nonce: bytes.Repeat([]byte{0x07}, tce.NonceSize),
}
b, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatal(err)
}
return b, issuer.Sign(b)
}
func TestWSMirroredThroughLightnode(t *testing.T) {
dir := t.TempDir()
a := newRelay(t, filepath.Join(dir, "a"))
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
subjAddr := subject.Identity().String()
mkClaim := func(nonce byte, serial uint64) ([]byte, []byte) {
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"ws.mirror": tce.Bool(true)},
CreatedAt: uint64(time.Now().Unix()),
Serial: serial,
Nonce: bytes.Repeat([]byte{nonce}, tce.NonceSize),
}
cb, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatal(err)
}
return cb, issuer.Sign(cb)
}
b, sig := mkClaim(0x60, 6)
a.putEnvelope(t, b, sig)
node := lightnode.New(lightnode.Config{
Peers: []string{a.ts.URL},
PinKeys: []string{getConfigPubkeys(t, a.ts.URL)},
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go node.Run(ctx, 30*time.Millisecond)
deadline := time.Now().Add(2 * time.Second)
for node.Decision() == nil {
if time.Now().After(deadline) {
t.Fatal("no quorum decision")
}
time.Sleep(5 * time.Millisecond)
}
wsn, err := node.WS()
if err != nil {
t.Fatal(err)
}
go wsn.RunWS(ctx)
lts := httptest.NewServer(node.Handler())
defer lts.Close()
wsURL := "ws" + strings.TrimPrefix(lts.URL, "http") + "/v1/ws"
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatal(err)
}
defer conn.Close(websocket.StatusNormalClosure, "")
readEvent := func() map[string]any {
t.Helper()
_, raw, err := conn.Read(ctx)
if err != nil {
t.Fatalf("read: %v", err)
}
var ev map[string]any
json.Unmarshal(raw, &ev)
return ev
}
subRaw, _ := json.Marshal(map[string]string{"op": "subscribe", "channel": "claims", "key": subjAddr})
if err := conn.Write(ctx, websocket.MessageText, subRaw); err != nil {
t.Fatal(err)
}
if ev := readEvent(); ev["event"] != "subscribed" {
t.Fatalf("no ack: %v", ev)
}
// The relay only broadcasts at store time, so wait until the mirror's
// upstream subscription is live before publishing the matched claim.
deadline2 := time.Now().Add(3 * time.Second)
for !wsn.UpstreamReady() {
if time.Now().After(deadline2) {
t.Fatal("upstream stream never came up")
}
time.Sleep(10 * time.Millisecond)
}
time.Sleep(150 * time.Millisecond) // let the relay process the subscribe frame
// A second claim about the subscribed subject flows through the mirror.
cb, csig := mkClaim(0x61, 7)
resp, err := http.Post(a.ts.URL+"/v1/objects", "application/json",
bytes.NewReader(mustJSON(t, map[string]any{"tce": b64(cb), "signature": b64(csig)})))
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
ev := readEvent()
if ev["event"] != "object" || ev["key"] != subjAddr {
t.Fatalf("unexpected event: %v", ev)
}
envMap, _ := ev["envelope"].(map[string]any)
if envMap == nil || envMap["tce"] == nil || envMap["signature"] == nil {
t.Fatal("event missing raw envelope")
}
if v, ok := ev["verified"].(bool); !ok || v {
t.Fatalf("fresh stream must be marked unverified: %v", ev["verified"])
}
// The streamed envelope must verify locally exactly like a fetched one.
tb, _ := base64.StdEncoding.DecodeString(envMap["tce"].(string))
tsig, _ := base64.StdEncoding.DecodeString(envMap["signature"].(string))
typName, verr := (&transport.Envelope{TCE: tb, Signature: tsig}).Verify()
if verr != nil || typName != "claim" {
t.Fatalf("streamed envelope fails local verification: %v %q", verr, typName)
}
}