87ef52cc80
The per-subject data-plane ACL existed since 0003e (membership.SubjectACLFor +
busauth.NewNkeyAuthenticatorACL, unit-tested in TestSubjectACLIsolation) but the
binary never used it: cmd/membershipd installed the plain NewNkeyAuthenticator, so
in production a registered NON-member could open a raw NATS connection,
Subscribe(">"), and harvest every room's subject plus JetStream stream/advisory
activity (payload stayed E2E ciphertext, metadata leaked) — the re-audit's H4
vector (report 0006).
Fix:
- New busauth.PermissionsFromSubjects adapts a subject-deriving function into the
PermissionsFunc the ACL authenticator expects (subjects granted as both the
publish and subscribe allow set; a derivation error fails closed). It lives in
busauth so membership stays free of the nats-server dependency.
- cmd/membershipd, under enforce, now installs
NewNkeyAuthenticatorACL(store.IsAuthorized,
PermissionsFromSubjects(membership.SubjectACLFor(store)))
so every connection is confined to the subjects of the rooms it belongs to plus
the client-infra subjects.
- pkg/membership/acl_test.go's helper now delegates to the production wiring
(PermissionsFromSubjects) instead of a test-only reimplementation, so the tests
exercise the real path.
Verification (pkg/membership/acl_test.go):
- TestReaudit_H4_WildcardMetadataLeak: a non-member's Subscribe(">") and any
foreign-subject subscribe raise permission violations; the member still pub/subs
her own room and the non-member captures nothing. With the plain authenticator
(the pre-0005e wiring) the test fails ("wildcard metadata leak still open"),
confirming the wiring is what closes it.
- TestSubjectACLIsolation / TestRefreshSessionGainsNewRoom still green.
- CGO_ENABLED=0 go build ./... && go vet ./... && go test -count=1 ./... green.
Residual (documented): the client-infra grant includes "$JS.API.>", shared by all
peers so per-connection JetStream works; a peer that subscribes specifically to
"$JS.API.>" can still observe stream-management requests whose subjects embed the
room-derived stream name. Fully closing that needs NATS accounts/permissions per
identity (deferred to the 0003 decentralization line). Operational note: NATS
freezes permissions at connect time, so clients must client.RefreshSession after a
membership change to gain a new room's subject; cmd/chat and cmd/worker do not yet
call it, a functional gap to close before an enforce+ACL deployment.
Refs: report 0006 H4, issue 0005e.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
383 lines
13 KiB
Go
383 lines
13 KiB
Go
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 (DOCUMENTED, not closed here): the client-infra grant includes
|
|
// "$JS.API.>", shared by all peers so per-connection JetStream works. A peer that
|
|
// subscribes specifically to "$JS.API.>" can still observe stream-management
|
|
// requests whose subjects embed the stream name derived from a room id. Fully
|
|
// closing that needs NATS accounts/permissions isolation per identity (deferred to
|
|
// the 0003 decentralization line). The high-impact leak the auditor exploited —
|
|
// the room subject itself and JetStream advisories captured via "Subscribe(\">\")"
|
|
// — is closed.
|
|
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)")
|
|
}
|
|
}
|