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) }