package main // Regression for audit report 0008, vector N3: the binary must wire the // replicated nonce store on a clustered node so a signed request accepted on one // node cannot be replayed to another. The auditor's ephemeral attack showed the // OLD binary never called UseReplicatedNonces (each node kept a per-process // cache), so a captured request replayed to a second node with 200+200. These // tests drive the SAME helper the binary uses (wireReplicatedNonces) so they // prove the WIRING, not just the underlying API. import ( "bytes" "crypto/rand" "encoding/base64" "encoding/hex" "io" "net" "net/http" "net/http/httptest" "path/filepath" "strconv" "testing" "time" cs "fn-registry/functions/cybersecurity" "github.com/enmanuel/unibus/pkg/blobstore" "github.com/enmanuel/unibus/pkg/embeddednats" "github.com/enmanuel/unibus/pkg/frame" "github.com/enmanuel/unibus/pkg/membership" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" ) func freePort(t *testing.T) int { t.Helper() l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("free port: %v", err) } defer l.Close() return l.Addr().(*net.TCPAddr).Port } // signed008 builds a transport-signed control-plane request with a caller-chosen // ts+nonce, so a test can reuse the exact same signed bytes against two nodes to // exercise replay. func signed008(t *testing.T, baseURL, method, path string, body []byte, id cs.Identity, ts int64, nonce string) *http.Request { t.Helper() canonical := membership.CanonicalRequest(method, path, strconv.FormatInt(ts, 10), nonce, body) sig := cs.SignEd25519(id.SignPriv, canonical) var rdr io.Reader if body != nil { rdr = bytes.NewReader(body) } req, err := http.NewRequest(method, baseURL+path, rdr) if err != nil { t.Fatalf("new request: %v", err) } req.Header.Set("X-Unibus-Pub", hex.EncodeToString(id.SignPub)) req.Header.Set("X-Unibus-Ts", strconv.FormatInt(ts, 10)) req.Header.Set("X-Unibus-Nonce", nonce) req.Header.Set("X-Unibus-Sig", base64.StdEncoding.EncodeToString(sig)) return req } func randNonce(t *testing.T) string { t.Helper() raw := make([]byte, 16) if _, err := rand.Read(raw); err != nil { t.Fatalf("nonce: %v", err) } return base64.StdEncoding.EncodeToString(raw) } // TestAttack0008_N3 is the blocker regression: two clustered membershipd nodes // wired through wireReplicatedNonces share a JetStream KV nonce bucket, so a // request accepted on node A is rejected (401) when replayed to node B. Before // the fix the binary never wired this and the replay returned 200. func TestAttack0008_N3(t *testing.T) { // One NATS+JetStream backing the shared nonce bucket (no client auth needed: // the test drives the membership.Server's nonce store directly via HTTP). ns, err := embeddednats.StartServer(embeddednats.ServerConfig{ StoreDir: t.TempDir(), Host: "127.0.0.1", Port: freePort(t), }) if err != nil { t.Fatalf("nats: %v", err) } t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() }) nc, err := nats.Connect(ns.ClientURL()) if err != nil { t.Fatalf("connect: %v", err) } t.Cleanup(nc.Close) js, err := jetstream.New(nc) if err != nil { t.Fatalf("jetstream: %v", err) } // Shared control-plane state (stand-in for the replicated store) + two nodes. dir := t.TempDir() store, err := membership.Open(filepath.Join(dir, "unibus.db")) if err != nil { t.Fatalf("store: %v", err) } t.Cleanup(func() { store.Close() }) alice, err := cs.GenerateIdentity() if err != nil { t.Fatalf("identity: %v", err) } if err := store.AddUser(hex.EncodeToString(alice.SignPub), "alice", membership.RoleAdmin); err != nil { t.Fatalf("add alice: %v", err) } blobs, _ := blobstore.New(filepath.Join(dir, "blobs")) // Each node is wired EXACTLY as the binary wires a clustered node. mkNode := func() *httptest.Server { srv := membership.NewServer(store, blobs, membership.AuthEnforce) if err := wireReplicatedNonces(srv, js, true /*clustered*/, 1); err != nil { t.Fatalf("wireReplicatedNonces: %v", err) } return httptest.NewServer(srv) } nodeA := mkNode() t.Cleanup(nodeA.Close) nodeB := mkNode() t.Cleanup(nodeB.Close) ts := time.Now().Unix() nonce := randNonce(t) path := "/members/" + frame.EndpointID(alice.SignPub) + "/rooms" // Golden: alice's signed request is accepted on node A. respA, err := http.DefaultClient.Do(signed008(t, nodeA.URL, "GET", path, nil, alice, ts, nonce)) if err != nil { t.Fatalf("do A: %v", err) } respA.Body.Close() if respA.StatusCode != http.StatusOK { t.Fatalf("node A first use: status %d, want 200", respA.StatusCode) } // Error path (the attack): replay the SAME signed bytes to node B → 401. respB, err := http.DefaultClient.Do(signed008(t, nodeB.URL, "GET", path, nil, alice, ts, nonce)) if err != nil { t.Fatalf("do B: %v", err) } respB.Body.Close() if respB.StatusCode != http.StatusUnauthorized { t.Fatalf("cross-node replay to node B: status %d, want 401 (replayed nonce must be rejected)", respB.StatusCode) } } // TestAttack0008_N3_StandaloneKeepsLocalCache is the edge: a NON-clustered node // must NOT require JetStream — wireReplicatedNonces is a no-op and the node keeps // its in-memory cache, which still rejects a same-node replay (the single-node // guarantee is unchanged). This proves the fix does not add a JetStream // dependency to standalone deployments. func TestAttack0008_N3_StandaloneKeepsLocalCache(t *testing.T) { dir := t.TempDir() store, err := membership.Open(filepath.Join(dir, "unibus.db")) if err != nil { t.Fatalf("store: %v", err) } t.Cleanup(func() { store.Close() }) alice, err := cs.GenerateIdentity() if err != nil { t.Fatalf("identity: %v", err) } if err := store.AddUser(hex.EncodeToString(alice.SignPub), "alice", membership.RoleAdmin); err != nil { t.Fatalf("add alice: %v", err) } blobs, _ := blobstore.New(filepath.Join(dir, "blobs")) srv := membership.NewServer(store, blobs, membership.AuthEnforce) // Standalone: clustered=false, js=nil. Must succeed (no JetStream needed). if err := wireReplicatedNonces(srv, nil, false /*clustered*/, 1); err != nil { t.Fatalf("standalone wireReplicatedNonces must be a no-op, got: %v", err) } node := httptest.NewServer(srv) t.Cleanup(node.Close) ts := time.Now().Unix() nonce := randNonce(t) path := "/members/" + frame.EndpointID(alice.SignPub) + "/rooms" resp1, err := http.DefaultClient.Do(signed008(t, node.URL, "GET", path, nil, alice, ts, nonce)) if err != nil { t.Fatalf("do 1: %v", err) } resp1.Body.Close() if resp1.StatusCode != http.StatusOK { t.Fatalf("first use: status %d, want 200", resp1.StatusCode) } // Same-node replay is still rejected by the in-memory cache. resp2, err := http.DefaultClient.Do(signed008(t, node.URL, "GET", path, nil, alice, ts, nonce)) if err != nil { t.Fatalf("do 2: %v", err) } resp2.Body.Close() if resp2.StatusCode != http.StatusUnauthorized { t.Fatalf("same-node replay: status %d, want 401", resp2.StatusCode) } } // TestAttack0008_N3_ClusteredRequiresJetStream proves the hard rule: a clustered // node with NO JetStream available refuses (error), so the binary fails fast // instead of silently running with a per-process cache. func TestAttack0008_N3_ClusteredRequiresJetStream(t *testing.T) { dir := t.TempDir() store, err := membership.Open(filepath.Join(dir, "unibus.db")) if err != nil { t.Fatalf("store: %v", err) } t.Cleanup(func() { store.Close() }) blobs, _ := blobstore.New(filepath.Join(dir, "blobs")) srv := membership.NewServer(store, blobs, membership.AuthEnforce) if err := wireReplicatedNonces(srv, nil, true /*clustered*/, 1); err == nil { t.Fatalf("clustered node with no JetStream must fail, got nil") } }