8b6a01d280
membershipd never called Server.UseReplicatedNonces, so every node kept a per-process anti-replay cache and a signed request accepted on node A could be replayed to node B (200+200). This wires the shared JetStream KV nonce bucket on any clustered node, closing the cross-node replay hole. Bootstrap: under enforce the service needs JetStream on its own embedded server, but the data plane only accepts allowlisted clients. Resolved with an ephemeral internal service identity the authenticator recognizes and grants full permissions (NewNkeyAuthenticatorACLInternal), connected over the in-process transport (no TLS/CA needed for the self-connection). Hard rule: --cluster-name != "" means the replicated nonce bucket is mandatory; if it cannot be created the node refuses to start (wireReplicatedNonces returns a fatal error) rather than run insecurely. Standalone nodes keep the in-memory cache unchanged (branch-by-abstraction: no JetStream dependency added). Changes: - busauth: NewNkeyAuthenticatorACLInternal + fullPermissions for the internal id. - cmd/membershipd: connectInternalJS (in-process, privileged) / connectExternalJS; wireReplicatedNonces helper; main wires it when clustered; --kv-replicas flag. Tests (regression of audit 0008 N3): - TestAttack0008_N3: 2 clustered nodes share the bucket, cross-node replay -> 401. - TestAttack0008_N3_StandaloneKeepsLocalCache: standalone needs no JetStream, same-node replay still 401. - TestAttack0008_N3_ClusteredRequiresJetStream: clustered + no JetStream -> fatal. - TestInternalConnPrivilegedUnderEnforce / ...OutsiderRejected: the privileged self-connection works under enforce and no other identity can claim it. CGO_ENABLED=0 go build/vet/test green; govulncheck 0 reachable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
120 lines
3.9 KiB
Go
120 lines
3.9 KiB
Go
package main
|
|
|
|
// Bootstrap test for issue 0006a/c: under enforce, membershipd must still reach
|
|
// JetStream on its OWN embedded server to create the nonce/KV buckets. It does so
|
|
// with an ephemeral internal identity the authenticator grants full permissions
|
|
// (NewNkeyAuthenticatorACLInternal). These tests prove that privileged
|
|
// self-connection works AND that no other identity can claim it.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
cs "fn-registry/functions/cybersecurity"
|
|
|
|
"github.com/enmanuel/unibus/pkg/busauth"
|
|
"github.com/enmanuel/unibus/pkg/embeddednats"
|
|
"github.com/nats-io/nats.go"
|
|
"github.com/nats-io/nats.go/jetstream"
|
|
)
|
|
|
|
func icFreePort(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
|
|
}
|
|
|
|
// TestInternalConnPrivilegedUnderEnforce: with an enforce authenticator that
|
|
// authorizes NO bus user, the internal identity still connects in-process and has
|
|
// full permissions — it creates a KV bucket and round-trips a value. This is the
|
|
// resolution of the bootstrap cycle the audit flagged as the reason the KV store
|
|
// was never wired.
|
|
func TestInternalConnPrivilegedUnderEnforce(t *testing.T) {
|
|
internalID, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("internal identity: %v", err)
|
|
}
|
|
internalPubHex := hex.EncodeToString(internalID.SignPub)
|
|
|
|
// Authenticator: no bus user is authorized; only the internal identity passes.
|
|
auth := busauth.NewNkeyAuthenticatorACLInternal(
|
|
func(string) bool { return false },
|
|
busauth.PermissionsFromSubjects(func(string) ([]string, error) { return []string{"_INBOX.>"}, nil }),
|
|
internalPubHex,
|
|
)
|
|
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
|
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: icFreePort(t), Auth: auth,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("nats: %v", err)
|
|
}
|
|
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
|
|
|
nc, js, err := connectInternalJS(ns, internalID, true /*useNkey*/)
|
|
if err != nil {
|
|
t.Fatalf("connectInternalJS: %v", err)
|
|
}
|
|
t.Cleanup(nc.Close)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "KV_UNIBUS_test", Replicas: 1})
|
|
if err != nil {
|
|
t.Fatalf("internal conn could not create KV bucket (full perms expected): %v", err)
|
|
}
|
|
if _, err := kv.Put(ctx, "k", []byte("v")); err != nil {
|
|
t.Fatalf("kv put: %v", err)
|
|
}
|
|
e, err := kv.Get(ctx, "k")
|
|
if err != nil || string(e.Value()) != "v" {
|
|
t.Fatalf("kv get: val=%q err=%v", e, err)
|
|
}
|
|
}
|
|
|
|
// TestInternalConnOutsiderRejected: an identity that is neither the internal one
|
|
// nor an allowlisted bus user cannot connect — proving the internal bypass is
|
|
// scoped to the exact internal key, not a blanket hole.
|
|
func TestInternalConnOutsiderRejected(t *testing.T) {
|
|
internalID, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("internal identity: %v", err)
|
|
}
|
|
auth := busauth.NewNkeyAuthenticatorACLInternal(
|
|
func(string) bool { return false },
|
|
busauth.PermissionsFromSubjects(func(string) ([]string, error) { return []string{"_INBOX.>"}, nil }),
|
|
hex.EncodeToString(internalID.SignPub),
|
|
)
|
|
ns, err := embeddednats.StartServer(embeddednats.ServerConfig{
|
|
StoreDir: t.TempDir(), Host: "127.0.0.1", Port: icFreePort(t), Auth: auth,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("nats: %v", err)
|
|
}
|
|
t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() })
|
|
|
|
outsider, err := cs.GenerateIdentity()
|
|
if err != nil {
|
|
t.Fatalf("outsider identity: %v", err)
|
|
}
|
|
pub, sign, err := busauth.ClientNkey(outsider.SignPriv)
|
|
if err != nil {
|
|
t.Fatalf("outsider nkey: %v", err)
|
|
}
|
|
conn, err := nats.Connect(ns.ClientURL(),
|
|
nats.Nkey(pub, sign),
|
|
nats.MaxReconnects(0),
|
|
nats.Timeout(2*time.Second),
|
|
)
|
|
if err == nil {
|
|
conn.Close()
|
|
t.Fatalf("outsider (unauthorized, non-internal) must be rejected, but connected")
|
|
}
|
|
}
|