package client_test import ( "bytes" "sync" "testing" "time" "github.com/enmanuel/unibus/pkg/client" "github.com/enmanuel/unibus/pkg/frame" "github.com/enmanuel/unibus/pkg/room" "github.com/nats-io/nats.go" ) // TestAudit_NoSubjectACL ports the auditor's H4 (Alto) finding under the minimum // defense chosen for this issue (forbid cleartext rooms in public; see // dev/0004d-dataplane-acl.md). The NATS data plane still has no per-subject ACL, // so the guarantee we make is CONTENT confidentiality, proven three ways: // // error : a cleartext (ModeNATS) room cannot be created under the public posture; // golden: a legitimate member (bob) decrypts the secret; // edge : eve, sniffing the raw subject off the data plane, receives only // ciphertext — she never recovers the plaintext the auditor's eve did. func TestAudit_NoSubjectACL(t *testing.T) { h := newHarness(t) h.srv.RequireEncryptedRooms = true // the public posture waitHealth(t, h.ctrlURL) alice, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t)) if err != nil { t.Fatalf("connect alice: %v", err) } defer alice.Close() // Error path: a cleartext room is refused, so no payload ever rides a subject // in the clear for a sniffer to read (the exact vector the auditor exploited). if _, err := alice.CreateRoom("secret.subject.payroll", room.ModeNATS); err == nil { t.Fatalf("cleartext room must be refused on a public deployment") } // alice creates an encrypted room and invites bob (the legitimate reader). const subject = "secret.subject.payroll.e2e" const secret = "internal: salary numbers" roomID, err := alice.CreateRoom(subject, room.ModeMatrix) if err != nil { t.Fatalf("alice create encrypted room: %v", err) } bob, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t)) if err != nil { t.Fatalf("connect bob: %v", err) } defer bob.Close() if err := alice.Invite(roomID, bob.Endpoint()); err != nil { t.Fatalf("alice invite bob: %v", err) } if err := bob.Join(roomID); err != nil { t.Fatalf("bob join: %v", err) } // Golden: bob (a member) subscribes and decrypts the secret. var bmu sync.Mutex var bobGot []string bobSub, err := bob.Subscribe(roomID, func(_ frame.Frame, plaintext []byte) { bmu.Lock() bobGot = append(bobGot, string(plaintext)) bmu.Unlock() }) if err != nil { t.Fatalf("bob subscribe: %v", err) } defer bobSub.Unsubscribe() // Edge: eve sniffs the raw subject directly off NATS (no membership, no key). rawEve, err := nats.Connect(h.natsURL) if err != nil { t.Fatalf("eve raw connect: %v", err) } defer rawEve.Close() eveGot := make(chan []byte, 8) if _, err := rawEve.Subscribe(subject, func(m *nats.Msg) { eveGot <- m.Data }); err != nil { t.Fatalf("eve raw subscribe: %v", err) } if err := rawEve.Flush(); err != nil { t.Fatalf("eve flush: %v", err) } time.Sleep(200 * time.Millisecond) // let both subscriptions settle if err := alice.Publish(roomID, []byte(secret)); err != nil { t.Fatalf("alice publish: %v", err) } // bob must decrypt the secret. if !waitFor(&bmu, &bobGot, func(rs []string) bool { for _, r := range rs { if r == secret { return true } } return false }, 2*time.Second) { t.Fatalf("bob (member) should decrypt the secret; got %v", snapshot(&bmu, &bobGot)) } // eve must receive only ciphertext — never the plaintext. select { case data := <-eveGot: if bytes.Contains(data, []byte(secret)) { t.Fatalf("eve sniffed the plaintext off the data plane: %q", data) } f, err := frame.Unmarshal(data) if err != nil { t.Fatalf("eve received an undecodable frame: %v", err) } if string(f.Payload) == secret { t.Fatalf("eve read the secret from the frame payload") } if len(f.Nonce) == 0 { t.Fatalf("expected an AEAD-encrypted payload (non-empty nonce), got cleartext frame") } case <-time.After(2 * time.Second): // eve receiving nothing is also a safe outcome; the assertion is only that // she never gets the plaintext, which holds vacuously here. } }