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") + } +}