From fb6c7960590ecf10fa89d81a6fa21e1e3657a76f Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 14:26:45 +0200 Subject: [PATCH] test: regression for H4 data-plane content confidentiality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pkg/membership TestRequireEncryptedRoomsRejectsCleartext: cleartext create -> 403, encrypted -> 201, flag off -> cleartext allowed again. pkg/client TestAudit_NoSubjectACL: under the public posture a ModeNATS room is refused; bob (member) decrypts the secret; eve raw-subscribes to the subject off the data plane and receives only ciphertext (non-empty AEAD nonce, no plaintext substring) — closing the auditor's 'eve reads internal: salary numbers'. --- pkg/client/client_test.go | 3 +- pkg/client/dataplane_acl_test.go | 124 +++++++++++++++++++++++++++++ pkg/membership/require_e2e_test.go | 46 +++++++++++ 3 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 pkg/client/dataplane_acl_test.go create mode 100644 pkg/membership/require_e2e_test.go diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 8a4745c..c556cd8 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -32,6 +32,7 @@ type testHarness struct { ns *server.Server httpts *httptest.Server store *membership.Store + srv *membership.Server } func freePort(t *testing.T) int { @@ -98,7 +99,7 @@ func bootHarness(t *testing.T, ctrlMode membership.AuthMode, natsAuth bool, nats srv := membership.NewServer(store, blobs, ctrlMode) httpts := httptest.NewServer(srv) - h := &testHarness{natsURL: embeddednats.ClientURL(ns), ctrlURL: httpts.URL, ns: ns, httpts: httpts, store: store} + h := &testHarness{natsURL: embeddednats.ClientURL(ns), ctrlURL: httpts.URL, ns: ns, httpts: httpts, store: store, srv: srv} t.Cleanup(func() { httpts.Close() store.Close() diff --git a/pkg/client/dataplane_acl_test.go b/pkg/client/dataplane_acl_test.go new file mode 100644 index 0000000..251ef51 --- /dev/null +++ b/pkg/client/dataplane_acl_test.go @@ -0,0 +1,124 @@ +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. + } +} diff --git a/pkg/membership/require_e2e_test.go b/pkg/membership/require_e2e_test.go new file mode 100644 index 0000000..36b3b5a --- /dev/null +++ b/pkg/membership/require_e2e_test.go @@ -0,0 +1,46 @@ +package membership + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +// TestRequireEncryptedRoomsRejectsCleartext is the control-plane half of the +// audit H4 minimum defense: with RequireEncryptedRooms on (the public posture), +// creating a cleartext (ModeNATS) room is refused 403, while an encrypted room is +// created normally. This is what guarantees no message ever rides the un-ACL'd +// NATS subject in the clear on a public deployment. +func TestRequireEncryptedRoomsRejectsCleartext(t *testing.T) { + srv := dosServer(t, AuthOff) + srv.RequireEncryptedRooms = true + + create := func(encrypt bool) int { + body, _ := json.Marshal(createRoomReq{ + Subject: "payroll.subject", + Policy: policyJSON{Encrypt: encrypt, Persist: encrypt, SignMsgs: encrypt}, + Owner: endpointJSON{Endpoint: "owner-ep", SignPub: []byte("sp"), KexPub: []byte("kp")}, + SealedKeySelf: []byte("sealed"), + }) + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/rooms", bytes.NewReader(body))) + return rec.Code + } + + // Error path: a cleartext room is refused. + if code := create(false); code != http.StatusForbidden { + t.Fatalf("cleartext room under RequireEncryptedRooms should be 403, got %d", code) + } + // Golden: an encrypted room is created. + if code := create(true); code != http.StatusCreated { + t.Fatalf("encrypted room should be 201, got %d", code) + } + + // Edge: with the flag OFF (loopback/dev), cleartext rooms are allowed again. + srv.RequireEncryptedRooms = false + if code := create(false); code != http.StatusCreated { + t.Fatalf("cleartext room with the flag off should be 201, got %d", code) + } +}