test: regression for H4 data-plane content confidentiality

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'.
This commit is contained in:
2026-06-07 14:26:45 +02:00
parent e502b16675
commit fb6c796059
3 changed files with 172 additions and 1 deletions
+2 -1
View File
@@ -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()
+124
View File
@@ -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.
}
}
+46
View File
@@ -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)
}
}