Merge issue/0001c-nats-nkey: NATS nkey authentication
Phase 0001c of issue 0001. The data plane now authenticates with each peer's Ed25519 identity reused as a NATS nkey: busauth converts the identity to an nkey and back, embeddednats installs a CustomClientAuthentication that verifies the nkey signature and checks the user allowlist on every connection (live revocation, no restart), and the client opts into nkey via NewWithOptions. The embedded server stays open by default so dev stacks and existing tests are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+31
-3
@@ -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)
|
||||
}
|
||||
|
||||
+75
-11
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user