Files
unibus/pkg/membership/authz_test.go
T
egutierrez b81e5f26f1 feat(membership): authorize room reads by membership, not registration
Audit H3 (Alto). 'Authorized' meant 'registered in the allowlist', not 'member
of the room', so any registered peer could read another room's subject, its
full member list (every member's sign_pub + kex_pub), any endpoint's room
directory, and even another member's sealed key.

The middleware now carries the authenticated signer's endpoint id into the
handler via request context. Room handlers enforce membership:
  - GET /rooms/{id} and /rooms/{id}/members require the signer to be a member;
  - GET /rooms/{id}/key serves the sealed key only to its own endpoint
    (endpoint == signer) and only to a member;
  - GET /members/{endpoint}/rooms is restricted to the signer's own endpoint.

Authorization is skipped only when no authenticated signer is present (AuthOff
dev, or a soft-mode pass-through), preserving legacy/dev behavior. Internal
errors no longer echo store messages to the client on these paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:21:55 +02:00

120 lines
4.4 KiB
Go

package membership
import (
"encoding/hex"
"net/http"
"strconv"
"testing"
"time"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/frame"
)
// seedRoom inserts an encrypted room owned by alice with a sealed key for her,
// directly through the store so the test controls membership precisely. It
// returns the room id and alice's endpoint.
func seedRoom(t *testing.T, h *authHarness, subject string) (string, string) {
t.Helper()
aliceEp := frame.EndpointID(h.alice.SignPub)
roomID := newULID()
info := RoomInfo{RoomID: roomID, Subject: subject, OwnerEndpoint: aliceEp, Encrypt: true}
if err := h.store.CreateRoom(info, h.alice.SignPub, h.alice.KexPub, []byte("alice-sealed-key")); err != nil {
t.Fatalf("seed room: %v", err)
}
return roomID, aliceEp
}
// register adds id to the bus allowlist so its signed requests clear auth and
// reach the handler, where membership authorization (not mere registration) is
// what the test exercises.
func register(t *testing.T, h *authHarness, id cs.Identity, handle string) {
t.Helper()
if err := h.store.AddUser(hex.EncodeToString(id.SignPub), handle, RoleMember); err != nil {
t.Fatalf("register %s: %v", handle, err)
}
}
// TestAudit_HorizontalMetadataLeak ports the auditor's H3 (Alto) finding: bob is
// REGISTERED on the bus but is NOT a member of alice's room. Before the fix the
// GET endpoints checked registration, not membership, so bob could read the
// room's subject, the full member list (with everyone's public keys), alice's
// room directory, and even alice's sealed key. Now every one of those returns
// 403 to bob, while alice (owner/member) and carol (plain member) get 200.
func TestAudit_HorizontalMetadataLeak(t *testing.T) {
h := newAuthHarness(t, AuthEnforce)
roomID, aliceEp := seedRoom(t, h, "secret.subject.payroll")
// bob: registered, never invited.
bob, _ := cs.GenerateIdentity()
register(t, h, bob, "bob")
// carol: registered AND a plain (non-owner) member — the legitimate-member edge.
carol, _ := cs.GenerateIdentity()
register(t, h, carol, "carol")
carolEp := frame.EndpointID(carol.SignPub)
if err := h.store.AddMember(roomID, Member{Endpoint: carolEp, Role: RoleMember, SignPub: carol.SignPub, KexPub: carol.KexPub}, 1, []byte("carol-sealed")); err != nil {
t.Fatalf("add carol: %v", err)
}
n := 0
get := func(id cs.Identity, path string) int {
n++
code, _ := do(t, signedReq(t, h.ts.URL, "GET", path, nil, id, time.Now().Unix(), nonceN(n)))
return code
}
// Error path: bob (non-member) is forbidden on every room endpoint.
bobChecks := []struct {
name string
path string
}{
{"get room", "/rooms/" + roomID},
{"list members", "/rooms/" + roomID + "/members"},
{"alice room directory", "/members/" + aliceEp + "/rooms"},
{"alice sealed key", "/rooms/" + roomID + "/key?endpoint=" + aliceEp},
{"bob sealed key in alices room", "/rooms/" + roomID + "/key?endpoint=" + frame.EndpointID(bob.SignPub)},
}
for _, c := range bobChecks {
if code := get(bob, c.path); code != http.StatusForbidden {
t.Fatalf("bob (non-member) %s should be 403, got %d", c.name, code)
}
}
// Golden: alice (owner/member) reads her room's metadata, members, directory, key.
aliceChecks := []string{
"/rooms/" + roomID,
"/rooms/" + roomID + "/members",
"/members/" + aliceEp + "/rooms",
"/rooms/" + roomID + "/key?endpoint=" + aliceEp,
}
for _, p := range aliceChecks {
if code := get(h.alice, p); code != http.StatusOK {
t.Fatalf("alice (owner) %s should be 200, got %d", p, code)
}
}
// Edge: carol is a plain member, not the owner — she may still read the room.
if code := get(carol, "/rooms/"+roomID); code != http.StatusOK {
t.Fatalf("carol (member) get room should be 200, got %d", code)
}
if code := get(carol, "/rooms/"+roomID+"/members"); code != http.StatusOK {
t.Fatalf("carol (member) list members should be 200, got %d", code)
}
// Edge: carol may fetch her OWN sealed key but not alice's.
if code := get(carol, "/rooms/"+roomID+"/key?endpoint="+carolEp); code != http.StatusOK {
t.Fatalf("carol fetching her own key should be 200, got %d", code)
}
if code := get(carol, "/rooms/"+roomID+"/key?endpoint="+aliceEp); code != http.StatusForbidden {
t.Fatalf("carol fetching alice's key should be 403, got %d", code)
}
}
// nonceN yields a distinct nonce per request so the anti-replay cache never
// rejects a fresh, legitimately-different request inside one test.
func nonceN(i int) string {
return "authz-nonce-" + strconv.Itoa(i)
}