From 1bcca987a44d301492851222dcf7724d1ea87987 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 14:36:22 +0200 Subject: [PATCH] test(membership): regression for H6 owner spoof and H7 nonce-cache poison TestAudit_OwnerSpoof: a body declaring a foreign owner endpoint or signing key is 403; a self-owned create is 201. TestAudit_NonceCachePoisonPreAuth: an unregistered identity's repeated nonce still fails 'not authorized' (never 'replayed'), proving it was not cached, while an authorized identity's replay is still rejected. Nonce cache unit tests: prune-after-TTL and cap-bounded memory. --- pkg/membership/nonce_cache_test.go | 51 +++++++++++++++++ pkg/membership/owner_nonce_test.go | 88 ++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 pkg/membership/nonce_cache_test.go create mode 100644 pkg/membership/owner_nonce_test.go diff --git a/pkg/membership/nonce_cache_test.go b/pkg/membership/nonce_cache_test.go new file mode 100644 index 0000000..0ff102a --- /dev/null +++ b/pkg/membership/nonce_cache_test.go @@ -0,0 +1,51 @@ +package membership + +import ( + "strconv" + "testing" + "time" +) + +// TestNonceCacheRememberPrune covers the replay/expiry behavior directly on the +// cache: a fresh nonce is accepted (golden), an immediate repeat is rejected +// (error), and after the TTL the same nonce is accepted again because its entry +// was pruned (edge). +func TestNonceCacheRememberPrune(t *testing.T) { + nc := newNonceCache(50*time.Millisecond, 1000) + base := time.Now() + + if !nc.rememberOrReject("a", base) { + t.Fatalf("first sighting should be accepted") + } + if nc.rememberOrReject("a", base) { + t.Fatalf("an immediate replay should be rejected") + } + if !nc.rememberOrReject("a", base.Add(60*time.Millisecond)) { + t.Fatalf("after the TTL the nonce should be accepted again (pruned)") + } +} + +// TestNonceCacheCapBounded covers the memory bound (audit H7): with a long TTL so +// nothing expires, inserting far more nonces than the cap must still keep the +// cache at or under the cap (oldest evicted), and the order queue must not drift +// from the map. +func TestNonceCacheCapBounded(t *testing.T) { + const capacity = 100 + nc := newNonceCache(time.Hour, capacity) + base := time.Now() + for i := 0; i < 500; i++ { + nc.rememberOrReject("n"+strconv.Itoa(i), base) + } + + nc.mu.Lock() + size := len(nc.seen) + orderLen := len(nc.order) + nc.mu.Unlock() + + if size > capacity { + t.Fatalf("cache exceeded its cap: %d > %d", size, capacity) + } + if orderLen != size { + t.Fatalf("order queue drifted from the map: order=%d seen=%d", orderLen, size) + } +} diff --git a/pkg/membership/owner_nonce_test.go b/pkg/membership/owner_nonce_test.go new file mode 100644 index 0000000..7e2e77f --- /dev/null +++ b/pkg/membership/owner_nonce_test.go @@ -0,0 +1,88 @@ +package membership + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/frame" +) + +// TestAudit_OwnerSpoof ports the auditor's H6 finding: handleCreateRoom did not +// bind the body's declared owner to the request signer, so a registered peer +// could create rooms in another identity's name. Now the owner endpoint AND the +// owner signing key must both be the authenticated signer's. +func TestAudit_OwnerSpoof(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + + bob, _ := cs.GenerateIdentity() + register(t, h, bob, "bob") + bobEp := frame.EndpointID(bob.SignPub) + victim, _ := cs.GenerateIdentity() + + post := func(id cs.Identity, owner endpointJSON, nonce string) int { + body, _ := json.Marshal(createRoomReq{Subject: "some.room", Owner: owner}) + code, _ := do(t, signedReq(t, h.ts.URL, "POST", "/rooms", body, id, time.Now().Unix(), nonce)) + return code + } + + // Error path: bob signs, body claims victim as owner -> 403. + if code := post(bob, endpointJSON{Endpoint: frame.EndpointID(victim.SignPub), SignPub: victim.SignPub, KexPub: victim.KexPub}, "spoof-1"); code != http.StatusForbidden { + t.Fatalf("owner-spoofed create should be 403, got %d", code) + } + + // Edge: bob declares his own endpoint but a foreign signing key -> 403 (the + // key, not just the endpoint string, is bound to the signer). + if code := post(bob, endpointJSON{Endpoint: bobEp, SignPub: victim.SignPub, KexPub: victim.KexPub}, "spoof-2"); code != http.StatusForbidden { + t.Fatalf("create with a foreign owner key should be 403, got %d", code) + } + + // Golden: alice creates a room owned by herself -> 201. + aliceEp := frame.EndpointID(h.alice.SignPub) + if code := post(h.alice, endpointJSON{Endpoint: aliceEp, SignPub: h.alice.SignPub, KexPub: h.alice.KexPub}, "owner-ok"); code != http.StatusCreated { + t.Fatalf("self-owned create should be 201, got %d", code) + } +} + +// TestAudit_NonceCachePoisonPreAuth ports the auditor's H7 finding: the replay +// cache was populated BEFORE the allowlist check, so any unregistered identity +// (Ed25519 keys are free) could seed nonces into it. Now IsAuthorized runs first, +// so an unauthorized identity's nonce is never cached: a repeat of the same nonce +// still fails as "not authorized", not "replayed nonce". +func TestAudit_NonceCachePoisonPreAuth(t *testing.T) { + h := newAuthHarness(t, AuthEnforce) + + eve, _ := cs.GenerateIdentity() // valid signatures, NOT on the allowlist + now := time.Now().Unix() + + code1, body1 := do(t, signedReq(t, h.ts.URL, "GET", "/rooms/x", nil, eve, now, "poison-nonce")) + if code1 != http.StatusUnauthorized || !strings.Contains(body1, "not authorized") { + t.Fatalf("unregistered first request should be 401 not-authorized, got %d (%s)", code1, body1) + } + + // Same nonce again: if the nonce had been cached, this would report "replayed + // nonce". It must still be "not authorized" — proving the nonce was NOT cached. + code2, body2 := do(t, signedReq(t, h.ts.URL, "GET", "/rooms/x", nil, eve, now, "poison-nonce")) + if code2 != http.StatusUnauthorized { + t.Fatalf("unregistered replay should still be 401, got %d", code2) + } + if strings.Contains(body2, "replayed") { + t.Fatalf("an unauthorized identity's nonce was cached pre-auth: %s", body2) + } + if !strings.Contains(body2, "not authorized") { + t.Fatalf("second unregistered request should still be not-authorized, got: %s", body2) + } + + // Positive control: an AUTHORIZED identity's replay IS still rejected, so the + // reorder did not weaken anti-replay for legitimate traffic. + if code, _ := do(t, signedReq(t, h.ts.URL, "GET", aliceRoomsPath(h), nil, h.alice, now, "alice-live")); code != http.StatusOK { + t.Fatalf("alice's first request should be 200, got %d", code) + } + if code, body := do(t, signedReq(t, h.ts.URL, "GET", aliceRoomsPath(h), nil, h.alice, now, "alice-live")); code != http.StatusUnauthorized || !strings.Contains(body, "replayed") { + t.Fatalf("alice's replay should be 401 replayed nonce, got %d (%s)", code, body) + } +}