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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user