package membership_test import ( "encoding/hex" "net" "net/http/httptest" "path/filepath" "testing" "time" cs "fn-registry/functions/cybersecurity" "github.com/enmanuel/unibus/pkg/blobstore" "github.com/enmanuel/unibus/pkg/busauth" "github.com/enmanuel/unibus/pkg/client" "github.com/enmanuel/unibus/pkg/embeddednats" "github.com/enmanuel/unibus/pkg/frame" "github.com/enmanuel/unibus/pkg/membership" "github.com/nats-io/nats.go" server "github.com/nats-io/nats-server/v2/server" ) func aclFreePort(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 } func mustID(t *testing.T) cs.Identity { t.Helper() id, err := cs.GenerateIdentity() if err != nil { t.Fatalf("identity: %v", err) } return id } // aclPermsFunc builds the per-subject PermissionsFunc the ACL authenticator // expects. It delegates to the SAME production wiring membershipd uses // (busauth.PermissionsFromSubjects over membership.SubjectACLFor), so this test // exercises the real path rather than a test-only reimplementation. func aclPermsFunc(store membership.Store) busauth.PermissionsFunc { return busauth.PermissionsFromSubjects(membership.SubjectACLFor(store)) } // startACLNats boots an embedded NATS whose authenticator confines each peer to // the subjects of the rooms it belongs to (audit H4 residual). func startACLNats(t *testing.T, store membership.Store) *server.Server { t.Helper() auth := busauth.NewNkeyAuthenticatorACL(store.IsAuthorized, aclPermsFunc(store)) ns, err := embeddednats.StartServer(embeddednats.ServerConfig{ StoreDir: t.TempDir(), Host: "127.0.0.1", Port: aclFreePort(t), Auth: auth, }) if err != nil { t.Fatalf("acl nats: %v", err) } t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() }) return ns } func nkeyConn(t *testing.T, natsURL string, id cs.Identity, errCh chan error) *nats.Conn { t.Helper() pub, sign, err := busauth.ClientNkey(id.SignPriv) if err != nil { t.Fatalf("nkey: %v", err) } nc, err := nats.Connect(natsURL, nats.Nkey(pub, sign), nats.ErrorHandler(func(_ *nats.Conn, _ *nats.Subscription, e error) { select { case errCh <- e: default: } }), ) if err != nil { t.Fatalf("connect nkey: %v", err) } t.Cleanup(nc.Close) return nc } func mustAddUser(t *testing.T, store membership.Store, id cs.Identity, handle string) { t.Helper() if err := store.AddUser(hex.EncodeToString(id.SignPub), handle, membership.RoleMember); err != nil { t.Fatalf("add user %s: %v", handle, err) } } func mustCreateRoom(t *testing.T, store membership.Store, roomID, subject, ownerEP string, owner cs.Identity) { t.Helper() info := membership.RoomInfo{RoomID: roomID, Subject: subject, OwnerEndpoint: ownerEP} if err := store.CreateRoom(info, owner.SignPub, owner.KexPub, nil); err != nil { t.Fatalf("create room %s: %v", roomID, err) } } func newCtrl(t *testing.T, store membership.Store, blobs blobstore.Store) string { t.Helper() ts := httptest.NewServer(membership.NewServer(store, blobs, membership.AuthOff)) t.Cleanup(ts.Close) return ts.URL } func waitErr(ch chan error, d time.Duration) error { select { case e := <-ch: return e case <-time.After(d): return nil } } func drain(ch chan error) { for { select { case <-ch: default: return } } } // TestSubjectACLIsolation closes the audit H4 residual: a registered peer is // confined to the subjects of the rooms it belongs to. alice (member of room.A) // may sub/pub room.A but is DENIED sub/pub on room.B, and never reads what bob // (member of room.B) publishes there. func TestSubjectACLIsolation(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, bob := mustID(t), mustID(t) aliceEP, bobEP := frame.EndpointID(alice.SignPub), frame.EndpointID(bob.SignPub) mustAddUser(t, store, alice, "alice") mustAddUser(t, store, bob, "bob") const subjA, subjB = "room.acl.a", "room.acl.b" mustCreateRoom(t, store, "ROOMA", subjA, aliceEP, alice) mustCreateRoom(t, store, "ROOMB", subjB, bobEP, bob) srv := startACLNats(t, store) url := srv.ClientURL() aliceErr := make(chan error, 4) bobErr := make(chan error, 4) aliceNC := nkeyConn(t, url, alice, aliceErr) bobNC := nkeyConn(t, url, bob, bobErr) // alice may subscribe to her own room (no error). aliceGot := make(chan string, 4) if _, err := aliceNC.Subscribe(subjA, func(m *nats.Msg) { aliceGot <- string(m.Data) }); err != nil { t.Fatalf("alice sub A: %v", err) } _ = aliceNC.Flush() if e := waitErr(aliceErr, 300*time.Millisecond); e != nil { t.Fatalf("alice sub to her OWN room raised an error: %v", e) } // alice subscribing to bob's room is a permissions violation. if _, err := aliceNC.Subscribe(subjB, func(m *nats.Msg) { aliceGot <- "LEAK:" + string(m.Data) }); err != nil { t.Fatalf("alice sub B (queue): %v", err) } _ = aliceNC.Flush() if e := waitErr(aliceErr, 1*time.Second); e == nil { t.Fatalf("alice subscribing to bob's room should raise a permissions violation") } // bob publishes in his room; alice (denied) must not receive it. bobGot := make(chan string, 4) if _, err := bobNC.Subscribe(subjB, func(m *nats.Msg) { bobGot <- string(m.Data) }); err != nil { t.Fatalf("bob sub B: %v", err) } _ = bobNC.Flush() if err := bobNC.Publish(subjB, []byte("internal-bob")); err != nil { t.Fatalf("bob pub B: %v", err) } _ = bobNC.Flush() select { case got := <-bobGot: if got != "internal-bob" { t.Fatalf("bob got %q", got) } case <-time.After(2 * time.Second): t.Fatalf("bob did not receive his own message") } select { case leak := <-aliceGot: t.Fatalf("alice received bob's room traffic despite the ACL: %q", leak) case <-time.After(500 * time.Millisecond): // good: alice never got it } // alice publishing into bob's room is denied; bob must not receive it. drain(aliceErr) if err := aliceNC.Publish(subjB, []byte("intruder")); err != nil { t.Fatalf("alice pub B (queue): %v", err) } _ = aliceNC.Flush() if e := waitErr(aliceErr, 1*time.Second); e == nil { t.Fatalf("alice publishing into bob's room should raise a permissions violation") } select { case got := <-bobGot: t.Fatalf("bob received alice's cross-room publish despite the ACL: %q", got) case <-time.After(500 * time.Millisecond): // good } } // TestReaudit_H4_WildcardMetadataLeak ports the re-auditor's H4 vector. Before // the per-subject ACL was WIRED into membershipd (it existed in pkg/membership and // pkg/busauth but the binary used the plain NewNkeyAuthenticator), a registered // NON-member could open a raw NATS connection, Subscribe(">"), and capture every // room's subject plus JetStream stream/advisory activity — the payload stayed E2E // ciphertext, but the metadata leaked. With NewNkeyAuthenticatorACL wired via the // production path (busauth.PermissionsFromSubjects(membership.SubjectACLFor)), a // non-member is confined to the client-infra subjects, so the wildcard and any // foreign room subject are denied. // // Coverage: // - error : a non-member's Subscribe(">") raises a permission violation; // - edge : a non-member subscribing to another room's exact subject is denied; // - golden: the member still pub/subs her own room, and the non-member never // captures that traffic. // // Residual now CLOSED (issue 0006b, audit 0008 N2): the client-infra grant no // longer includes "$JS.API.>". JetStream API access is granted per-room only // (membership.jsSubjectsFor), so a peer can reach the API of its OWN rooms' // streams but not the control-plane KV buckets (KV_UNIBUS_*) nor another room's // stream. See TestAttack0008_N2 for the closed-leak regression. func TestReaudit_H4_WildcardMetadataLeak(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, eve := mustID(t), mustID(t) aliceEP := frame.EndpointID(alice.SignPub) mustAddUser(t, store, alice, "alice") mustAddUser(t, store, eve, "eve") // eve is REGISTERED but never a member of alice's room const subject = "room.e2e.confidential" mustCreateRoom(t, store, "ROOMA", subject, aliceEP, alice) srv := startACLNats(t, store) url := srv.ClientURL() eveErr := make(chan error, 8) eveNC := nkeyConn(t, url, eve, eveErr) eveAll := make(chan *nats.Msg, 16) // Error: eve's wildcard subscription is rejected. nats.go creates the local sub // object and the server rejects it asynchronously (delivered to ErrorHandler). if _, err := eveNC.Subscribe(">", func(m *nats.Msg) { eveAll <- m }); err != nil { t.Fatalf("eve sub >: %v", err) } _ = eveNC.Flush() if e := waitErr(eveErr, 1*time.Second); e == nil { t.Fatalf("a non-member's Subscribe(\">\") must raise a permissions violation (wildcard metadata leak still open)") } // Edge: eve subscribing to the foreign room's EXACT subject is also denied. drain(eveErr) if _, err := eveNC.Subscribe(subject, func(m *nats.Msg) { eveAll <- m }); err != nil { t.Fatalf("eve sub subject: %v", err) } _ = eveNC.Flush() if e := waitErr(eveErr, 1*time.Second); e == nil { t.Fatalf("a non-member subscribing to another room's subject must be denied") } // Golden: alice (the member) pub/subs her own room with no violation, and eve // never captured the traffic despite her (rejected) wildcard. aliceErr := make(chan error, 4) aliceNC := nkeyConn(t, url, alice, aliceErr) aliceGot := make(chan string, 4) if _, err := aliceNC.Subscribe(subject, func(m *nats.Msg) { aliceGot <- string(m.Data) }); err != nil { t.Fatalf("alice sub own room: %v", err) } _ = aliceNC.Flush() if e := waitErr(aliceErr, 300*time.Millisecond); e != nil { t.Fatalf("alice subscribing to her OWN room raised an error: %v", e) } if err := aliceNC.Publish(subject, []byte("members-only metadata")); err != nil { t.Fatalf("alice publish: %v", err) } _ = aliceNC.Flush() select { case got := <-aliceGot: if got != "members-only metadata" { t.Fatalf("alice got %q", got) } case <-time.After(2 * time.Second): t.Fatalf("alice did not receive her own room's message") } select { case m := <-eveAll: t.Fatalf("eve captured room traffic despite the ACL: subject=%q data=%q", m.Subject, m.Data) case <-time.After(500 * time.Millisecond): // good: eve captured nothing } } // TestRefreshSessionGainsNewRoom is the "permissions refreshed on join" path: // alice is not in room B, so her connection has no permission for its subject; // after she is added to room B and calls RefreshSession, the reconnect // re-derives her permissions and she gains the room's subject. func TestRefreshSessionGainsNewRoom(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, bob := mustID(t), mustID(t) aliceEP, bobEP := frame.EndpointID(alice.SignPub), frame.EndpointID(bob.SignPub) mustAddUser(t, store, alice, "alice") mustAddUser(t, store, bob, "bob") const subjB = "room.refresh.b" mustCreateRoom(t, store, "ROOMB", subjB, bobEP, bob) srv := startACLNats(t, store) blobs, _ := blobstore.New(filepath.Join(dir, "blobs")) ctrl := newCtrl(t, store, blobs) aliceC, err := client.NewWithOptions(srv.ClientURL(), ctrl, alice, client.Options{UseNkey: true}) if err != nil { t.Fatalf("connect alice: %v", err) } defer aliceC.Close() // Add alice to room B (as if invited), then RefreshSession so the // authenticator re-derives her permissions on reconnect. if _, err := store.GetMember("ROOMB", aliceEP); err == nil { t.Fatalf("alice should not be a member yet") } if err := store.AddMember("ROOMB", membership.Member{Endpoint: aliceEP, Role: "member", SignPub: alice.SignPub, KexPub: alice.KexPub}, 1, nil); err != nil { t.Fatalf("add alice to room B: %v", err) } if err := aliceC.RefreshSession(); err != nil { t.Fatalf("refresh session: %v", err) } bobErr := make(chan error, 2) bobNC := nkeyConn(t, srv.ClientURL(), bob, bobErr) got := make(chan string, 2) sub, err := aliceC.Subscribe("ROOMB", func(_ frame.Frame, plaintext []byte) { got <- string(plaintext) }) if err != nil { t.Fatalf("alice subscribe room B after refresh: %v", err) } defer sub.Unsubscribe() time.Sleep(200 * time.Millisecond) // bob publishes a minimal cleartext frame on subjB. f := frame.Frame{Type: frame.PUB, Subject: subjB, Sender: bobEP, MsgID: "m1", Payload: []byte("hello-after-join")} b, _ := f.Marshal() if err := bobNC.Publish(subjB, b); err != nil { t.Fatalf("bob publish: %v", err) } _ = bobNC.Flush() select { case msg := <-got: if msg != "hello-after-join" { t.Fatalf("alice got %q", msg) } case <-time.After(3 * time.Second): t.Fatalf("alice did not receive room B traffic after RefreshSession (permissions not refreshed)") } }