fb6c796059
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'.
125 lines
3.9 KiB
Go
125 lines
3.9 KiB
Go
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.
|
|
}
|
|
}
|