diff --git a/pkg/busauth/authenticator.go b/pkg/busauth/authenticator.go new file mode 100644 index 00000000..3de74a0b --- /dev/null +++ b/pkg/busauth/authenticator.go @@ -0,0 +1,57 @@ +package busauth + +import ( + "encoding/base64" + + server "github.com/nats-io/nats-server/v2/server" + "github.com/nats-io/nkeys" +) + +// nkeyAuthenticator is a NATS server.Authentication that authorizes a client by +// verifying the nkey signature over the server-presented nonce and then +// consulting the bus user allowlist. Authorization is checked on every new +// connection via the injected predicate (not a static Options.Nkeys map), so +// revoking a user denies its next connection without restarting the server. +type nkeyAuthenticator struct { + // isAuthorized reports whether the lowercase-hex Ed25519 public key behind an + // nkey belongs to an active bus user. Injected (membership.Store.IsAuthorized) + // so this package stays free of the store dependency. + isAuthorized func(signPubHex string) bool +} + +// NewNkeyAuthenticator builds a NATS custom authenticator backed by isAuthorized. +// Pass it to embeddednats so the data plane only accepts registered identities. +func NewNkeyAuthenticator(isAuthorized func(signPubHex string) bool) server.Authentication { + return &nkeyAuthenticator{isAuthorized: isAuthorized} +} + +// Check verifies the client's nkey signature against the nonce the server +// presented, then maps the nkey to its allowlist key and checks authorization. +// Any malformed input or failed verification yields false (fail closed). The +// signature decoding mirrors nats-server's own (raw-url base64, then std base64 +// fallback) so genuine clients using nats.Nkey are accepted unchanged. +func (a *nkeyAuthenticator) Check(c server.ClientAuthentication) bool { + opts := c.GetOpts() + if opts.Nkey == "" { + return false + } + sig, err := base64.RawURLEncoding.DecodeString(opts.Sig) + if err != nil { + sig, err = base64.StdEncoding.DecodeString(opts.Sig) + if err != nil { + return false + } + } + pub, err := nkeys.FromPublicKey(opts.Nkey) + if err != nil { + return false + } + if err := pub.Verify(c.GetNonce(), sig); err != nil { + return false + } + signPubHex, err := SignPubHexFromNkey(opts.Nkey) + if err != nil { + return false + } + return a.isAuthorized(signPubHex) +} diff --git a/pkg/busauth/nkey.go b/pkg/busauth/nkey.go new file mode 100644 index 00000000..239e03b7 --- /dev/null +++ b/pkg/busauth/nkey.go @@ -0,0 +1,76 @@ +// Package busauth bridges a unibus peer's Ed25519 identity to NATS nkey +// authentication. A NATS nkey IS an Ed25519 keypair, so the bus reuses the +// peer's existing signing identity for the data plane instead of minting new +// key material — one identity authenticates both planes (HTTP request signatures +// and NATS connections), keyed in the user allowlist by the same Ed25519 public +// key. +// +// This is transport glue specific to NATS + unibus, not a general-purpose +// registry primitive: it deliberately lives in the app to avoid pulling +// github.com/nats-io/nkeys into the multi-domain registry module. The Ed25519 +// signing/verification it relies on comes from the registry cybersecurity +// package; this package never reimplements a primitive. +package busauth + +import ( + "crypto/ed25519" + "encoding/hex" + "fmt" + + "github.com/nats-io/nkeys" +) + +// ClientNkey derives, from a peer's Ed25519 private key, the NATS user nkey +// public string ("U...") and a signature callback suitable for +// nats.Nkey(pub, sign). The callback signs the server-presented nonce with the +// same Ed25519 key, so the server can verify it and map it back to the bus user. +// +// signPriv must be a 64-byte Ed25519 private key (as produced by the registry's +// GenerateIdentity). Its first 32 bytes are the seed nkeys needs. +func ClientNkey(signPriv []byte) (pub string, sign func([]byte) ([]byte, error), err error) { + if len(signPriv) != ed25519.PrivateKeySize { + return "", nil, fmt.Errorf("busauth: signPriv must be %d bytes, got %d", ed25519.PrivateKeySize, len(signPriv)) + } + seed := ed25519.PrivateKey(signPriv).Seed() // 32-byte Ed25519 seed + kp, err := nkeys.FromRawSeed(nkeys.PrefixByteUser, seed) + if err != nil { + return "", nil, fmt.Errorf("busauth: derive nkey from seed: %w", err) + } + pub, err = kp.PublicKey() + if err != nil { + return "", nil, fmt.Errorf("busauth: nkey public key: %w", err) + } + sign = func(nonce []byte) ([]byte, error) { + return kp.Sign(nonce) + } + return pub, sign, nil +} + +// NkeyPublicFromSignPub derives the NATS user nkey public string from a 32-byte +// Ed25519 public key. It is the inverse view of the identity used by callers +// that have only the public key (e.g. to display or pre-register an nkey). +func NkeyPublicFromSignPub(signPub []byte) (string, error) { + if len(signPub) != ed25519.PublicKeySize { + return "", fmt.Errorf("busauth: signPub must be %d bytes, got %d", ed25519.PublicKeySize, len(signPub)) + } + pub, err := nkeys.Encode(nkeys.PrefixByteUser, signPub) + if err != nil { + return "", fmt.Errorf("busauth: encode nkey public: %w", err) + } + return string(pub), nil +} + +// SignPubHexFromNkey decodes a NATS user nkey public string ("U...") back to the +// lowercase hex of its 32-byte Ed25519 public key — the identity key used to +// look a peer up in the bus user allowlist. The server calls this to map the +// nkey a client presented to the users table. +func SignPubHexFromNkey(nkeyPub string) (string, error) { + raw, err := nkeys.Decode(nkeys.PrefixByteUser, []byte(nkeyPub)) + if err != nil { + return "", fmt.Errorf("busauth: decode nkey %q: %w", nkeyPub, err) + } + if len(raw) != ed25519.PublicKeySize { + return "", fmt.Errorf("busauth: decoded nkey is %d bytes, want %d", len(raw), ed25519.PublicKeySize) + } + return hex.EncodeToString(raw), nil +} diff --git a/pkg/busauth/nkey_test.go b/pkg/busauth/nkey_test.go new file mode 100644 index 00000000..24aca91a --- /dev/null +++ b/pkg/busauth/nkey_test.go @@ -0,0 +1,85 @@ +package busauth + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "testing" + + cs "fn-registry/functions/cybersecurity" + + "github.com/nats-io/nkeys" +) + +// TestNkeyRoundTrip is the dedicated sign/verify round-trip the spec requires +// BEFORE the NATS server depends on this conversion. It proves three things end +// to end: (1) ClientNkey produces a signature callback whose output verifies +// under the derived nkey public key; (2) that signature is exactly the Ed25519 +// signature of the same identity (the nkey is the same key, not a new one); +// (3) the nkey public string maps back to the identity's Ed25519 hex, which is +// the key the allowlist is indexed by. +func TestNkeyRoundTrip(t *testing.T) { + id, err := cs.GenerateIdentity() + if err != nil { + t.Fatalf("identity: %v", err) + } + + pub, sign, err := ClientNkey(id.SignPriv) + if err != nil { + t.Fatalf("ClientNkey: %v", err) + } + + // (1) The callback's signature over a server-style nonce verifies under the + // public nkey, exactly as the NATS server will verify it. + nonce := []byte("server-presented-nonce-1234567890") + sig, err := sign(nonce) + if err != nil { + t.Fatalf("sign: %v", err) + } + kpPub, err := nkeys.FromPublicKey(pub) + if err != nil { + t.Fatalf("FromPublicKey: %v", err) + } + if err := kpPub.Verify(nonce, sig); err != nil { + t.Fatalf("nkey verify failed: %v", err) + } + + // (2) The signature is the very same bytes as a raw Ed25519 sign with the + // identity's private key — confirming no separate key material was minted. + want := ed25519.Sign(ed25519.PrivateKey(id.SignPriv), nonce) + if !bytes.Equal(sig, want) { + t.Fatalf("nkey signature differs from Ed25519 signature of the same identity") + } + + // (3) The nkey public maps back to the identity's Ed25519 hex (allowlist key). + gotHex, err := SignPubHexFromNkey(pub) + if err != nil { + t.Fatalf("SignPubHexFromNkey: %v", err) + } + if gotHex != hex.EncodeToString(id.SignPub) { + t.Fatalf("nkey->hex mismatch: got %s want %s", gotHex, hex.EncodeToString(id.SignPub)) + } + + // And NkeyPublicFromSignPub is consistent with ClientNkey's public. + pub2, err := NkeyPublicFromSignPub(id.SignPub) + if err != nil { + t.Fatalf("NkeyPublicFromSignPub: %v", err) + } + if pub2 != pub { + t.Fatalf("public nkey mismatch between derivations: %s vs %s", pub2, pub) + } +} + +// Error path: a wrong-length private key is rejected, not silently misused. +func TestClientNkeyBadKey(t *testing.T) { + if _, _, err := ClientNkey([]byte("too-short")); err == nil { + t.Fatalf("expected error for short private key") + } +} + +// Error path: a non-nkey string does not decode to an allowlist key. +func TestSignPubHexFromNkeyBad(t *testing.T) { + if _, err := SignPubHexFromNkey("not-a-real-nkey"); err == nil { + t.Fatalf("expected error decoding a bogus nkey") + } +} diff --git a/pkg/client/client.go b/pkg/client/client.go index 52d05e02..3d84db20 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -28,6 +28,7 @@ import ( cs "fn-registry/functions/cybersecurity" + "github.com/enmanuel/unibus/pkg/busauth" "github.com/enmanuel/unibus/pkg/frame" "github.com/enmanuel/unibus/pkg/membership" "github.com/enmanuel/unibus/pkg/room" @@ -56,10 +57,37 @@ type Client struct { signCache map[string][]byte // sender endpoint -> sign pub (for verification) } -// New connects to NATS and records the control-plane URL. The identity holds -// the peer's long-term keypairs. +// Options configures how a client connects to the bus. The zero value is the +// legacy behavior: a plain NATS connection with no nkey and no TLS — what dev +// stacks and a not-yet-secured server expect. Secured deployments set these. +type Options struct { + // UseNkey authenticates the NATS connection with the peer's Ed25519 identity + // reused as a NATS nkey. It MUST match the server: nats.go refuses to connect + // with an nkey to a server that does not advertise nkey auth ("nkeys not + // supported by the server"), so this is opt-in rather than always-on. + UseNkey bool +} + +// New connects to NATS and records the control-plane URL with default Options +// (no nkey, no TLS). The identity holds the peer's long-term keypairs. func New(natsURL, ctrlURL string, id cs.Identity) (*Client, error) { - nc, err := nats.Connect(natsURL, nats.Name("unibus-client")) + return NewWithOptions(natsURL, ctrlURL, id, Options{}) +} + +// NewWithOptions is New with explicit connection options (nkey auth, and, from +// phase 0001d, TLS). It is the single place the data-plane connection is built, +// so every peer (worker, chat, mobile, gateway) gets identical behavior by +// passing the same Options. +func NewWithOptions(natsURL, ctrlURL string, id cs.Identity, opts Options) (*Client, error) { + natsOpts := []nats.Option{nats.Name("unibus-client")} + if opts.UseNkey { + nkeyPub, nkeySign, err := busauth.ClientNkey(id.SignPriv) + if err != nil { + return nil, fmt.Errorf("client: derive nkey: %w", err) + } + natsOpts = append(natsOpts, nats.Nkey(nkeyPub, nkeySign)) + } + nc, err := nats.Connect(natsURL, natsOpts...) if err != nil { return nil, fmt.Errorf("client: connect nats %q: %w", natsURL, err) } diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 1766d4a4..00d199cb 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -14,6 +14,7 @@ import ( 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" @@ -42,31 +43,49 @@ func freePort(t *testing.T) int { return l.Addr().(*net.TCPAddr).Port } -func newHarness(t *testing.T) *testHarness { return newHarnessMode(t, membership.AuthOff) } +func newHarness(t *testing.T) *testHarness { return newHarnessFull(t, membership.AuthOff, false) } -// newHarnessMode is newHarness with an explicit control-plane auth mode, so auth -// tests can boot the real server in enforce/soft and exercise it through the -// production client (which signs every request). +// newHarnessMode is newHarness with an explicit control-plane auth mode and the +// NATS data plane left open (no nkey auth), so HTTP-auth tests can use a plain +// client.New that does not present an nkey. func newHarnessMode(t *testing.T, mode membership.AuthMode) *testHarness { + return newHarnessFull(t, mode, false) +} + +// newHarnessFull boots the embedded NATS (optionally with the nkey authenticator +// backed by the user allowlist) and the membershipd HTTP server in ctrlMode. +// natsAuth and ctrlMode are independent on purpose: an HTTP-enforce test can +// keep NATS open, and an nkey test can keep HTTP off, mirroring how the rollout +// flags compose. The store is created before NATS so the authenticator can +// consult IsAuthorized for live revocation. +func newHarnessFull(t *testing.T, ctrlMode membership.AuthMode, natsAuth bool) *testHarness { t.Helper() dir := t.TempDir() - ns, err := embeddednats.Start(filepath.Join(dir, "js"), freePort(t)) + store, err := membership.Open(filepath.Join(dir, "unibus.db")) if err != nil { + t.Fatalf("membership store: %v", err) + } + + var ns *server.Server + if natsAuth { + ns, err = embeddednats.StartHostAuth(filepath.Join(dir, "js"), "127.0.0.1", freePort(t), + busauth.NewNkeyAuthenticator(store.IsAuthorized)) + } else { + ns, err = embeddednats.Start(filepath.Join(dir, "js"), freePort(t)) + } + if err != nil { + store.Close() t.Fatalf("embedded nats: %v", err) } - store, err := membership.Open(filepath.Join(dir, "unibus.db")) - if err != nil { - ns.Shutdown() - t.Fatalf("membership store: %v", err) - } blobs, err := blobstore.New(filepath.Join(dir, "blobs")) if err != nil { ns.Shutdown() + store.Close() t.Fatalf("blob store: %v", err) } - srv := membership.NewServer(store, blobs, mode) + 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} @@ -516,6 +535,51 @@ func TestControlPlaneAuthEnforceE2E(t *testing.T) { } } +// TestNatsNkeyAuth exercises the data-plane authenticator: with NATS nkey auth +// on, a registered peer connecting with its nkey is accepted and can publish +// (golden); an unregistered peer is refused at connect time (error path); and a +// peer revoked while the server runs is refused on its NEXT connection, proving +// revocation without a restart (edge). +func TestNatsNkeyAuth(t *testing.T) { + h := newHarnessFull(t, membership.AuthOff, true) // NATS auth on; HTTP off to isolate the data plane + waitHealth(t, h.ctrlURL) + + idA := mustIdentity(t) + if err := h.store.AddUser(hex.EncodeToString(idA.SignPub), "alice", membership.RoleMember); err != nil { + t.Fatalf("register A: %v", err) + } + + // Golden: registered peer connects with its nkey and uses the bus. + a, err := client.NewWithOptions(h.natsURL, h.ctrlURL, idA, client.Options{UseNkey: true}) + if err != nil { + t.Fatalf("registered peer should connect with nkey: %v", err) + } + defer a.Close() + if _, err := a.CreateRoom("room.nkey", room.ModeNATS); err != nil { + t.Fatalf("registered peer should operate: %v", err) + } + + // Error path: an unregistered identity is refused at connect time. + idB := mustIdentity(t) + if _, err := client.NewWithOptions(h.natsURL, h.ctrlURL, idB, client.Options{UseNkey: true}); err == nil { + t.Fatalf("unregistered peer must be refused by the NATS authenticator") + } + + // Error path: presenting no nkey to an auth-required server is refused. + if _, err := client.NewWithOptions(h.natsURL, h.ctrlURL, idB, client.Options{UseNkey: false}); err == nil { + t.Fatalf("a client without an nkey must be refused when the server requires auth") + } + + // Edge: revoke A while the server runs; A's NEXT connection is refused even + // though an already-open connection (a) is unaffected. No server restart. + if err := h.store.RevokeUser(hex.EncodeToString(idA.SignPub)); err != nil { + t.Fatalf("revoke A: %v", err) + } + if _, err := client.NewWithOptions(h.natsURL, h.ctrlURL, idA, client.Options{UseNkey: true}); err == nil { + t.Fatalf("revoked peer must be refused on a new connection without a restart") + } +} + // ---- test helpers --------------------------------------------------------- type collector struct { diff --git a/pkg/embeddednats/embeddednats.go b/pkg/embeddednats/embeddednats.go index 1116b97e..7291fdad 100644 --- a/pkg/embeddednats/embeddednats.go +++ b/pkg/embeddednats/embeddednats.go @@ -30,6 +30,16 @@ func Start(storeDir string, port int) (*server.Server, error) { // to expose it to the LAN so remote peers (phones, other PCs) can connect. An // empty host falls back to the nats-server default ("0.0.0.0", all interfaces). func StartHost(storeDir, host string, port int) (*server.Server, error) { + return StartHostAuth(storeDir, host, port, nil) +} + +// StartHostAuth is StartHost with an optional custom client authenticator. When +// auth is non-nil it is installed as Options.CustomClientAuthentication, so the +// data plane only accepts clients the authenticator approves (nkey signature + +// bus allowlist). When auth is nil the server accepts any client (the legacy, +// network-trusted behavior) — used by dev stacks and tests that have not enabled +// bus auth. +func StartHostAuth(storeDir, host string, port int, auth server.Authentication) (*server.Server, error) { opts := &server.Options{ JetStream: true, StoreDir: storeDir, @@ -40,6 +50,14 @@ func StartHost(storeDir, host string, port int) (*server.Server, error) { NoLog: true, NoSigs: true, } + if auth != nil { + opts.CustomClientAuthentication = auth + // A CustomClientAuthentication alone does not make the server advertise a + // nonce in its INFO line, and nats.go refuses to connect with an nkey to a + // server that does not ("nkeys not supported by the server"). Forcing the + // nonce makes nkey clients sign the challenge our authenticator verifies. + opts.AlwaysEnableNonce = true + } ns, err := server.NewServer(opts) if err != nil {