// Bridges the user's Ed25519 identity to the two authentication surfaces of the // bus, ported from Go pkg/busauth and the client's request signing: // // - DATA PLANE (NATS): a NATS user nkey IS an Ed25519 keypair. nkeyPublic encodes // the Ed25519 public key into the "U..." nkey string the server expects, and // natsAuthenticator signs the server-presented nonce with the same key — so the // browser authenticates to NATS with the user's identity, no extra key material. // - CONTROL PLANE (HTTP): every request to membershipd is signed. canonicalRequest // reproduces Go's membership.CanonicalRequest, and signedHeaders attaches the // X-Unibus-Pub/Ts/Nonce/Sig headers the server verifies. // // Parity with Go is pinned by the `nkey` and `control_request` vectors in // testdata/vectors.json (busauth.test.ts). import { sha256 } from "@noble/hashes/sha2.js"; import { signEd25519, bytesToHex, bytesToBase64 } from "./crypto.js"; // --- NATS nkey encoding (base32 + crc16, matching github.com/nats-io/nkeys) --- // PrefixByteUser is nkeys' user prefix (20 << 3). Its top 5 bits encode to 'U', so // every user nkey string starts with "U". const PREFIX_USER = 20 << 3; // crc16 table (CRC-16/XMODEM, poly 0x1021, MSB-first) — the exact CRC nkeys appends. const CRC16TAB: Uint16Array = (() => { const tab = new Uint16Array(256); for (let i = 0; i < 256; i++) { let crc = (i << 8) & 0xffff; for (let j = 0; j < 8; j++) { crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; } tab[i] = crc; } return tab; })(); function crc16(data: Uint8Array): number { let crc = 0; for (const b of data) crc = ((crc << 8) & 0xffff) ^ CRC16TAB[((crc >> 8) ^ b) & 0xff]; return crc & 0xffff; } const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; // base32Encode is RFC4648 standard base32 WITHOUT padding, as nkeys uses. function base32Encode(data: Uint8Array): string { let bits = 0; let value = 0; let out = ""; for (const b of data) { value = (value << 8) | b; bits += 8; while (bits >= 5) { out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31]; return out; } // nkeyPublic encodes a 32-byte Ed25519 public key as a NATS user nkey ("U..."). // Layout: prefixByte || pubkey(32) || crc16-little-endian(2), base32 (no padding). export function nkeyPublic(signPub: Uint8Array): string { if (signPub.length !== 32) throw new Error(`nkeyPublic: signPub must be 32 bytes, got ${signPub.length}`); const raw = new Uint8Array(1 + 32); raw[0] = PREFIX_USER; raw.set(signPub, 1); const crc = crc16(raw); const full = new Uint8Array(raw.length + 2); full.set(raw, 0); full[raw.length] = crc & 0xff; // little-endian full[raw.length + 1] = (crc >> 8) & 0xff; return base32Encode(full); } // natsAuthenticator returns the callback a NATS WebSocket connection uses to // authenticate: it presents the user nkey and signs the server's nonce with the // Ed25519 key. The nonce arrives as a string; we sign its UTF-8 bytes and return the // signature base64url-encoded, the form the NATS protocol expects. export function natsAuthenticator(signPub: Uint8Array, signPriv: Uint8Array) { const nkey = nkeyPublic(signPub); return (nonce: string) => { const sig = signEd25519(signPriv, new TextEncoder().encode(nonce)); const b64url = bytesToBase64(sig).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return { nkey, sig: b64url }; }; } // --- control-plane request signing (HTTP) ------------------------------------ // canonicalRequest reproduces Go's membership.CanonicalRequest: the bytes signed for // a control-plane HTTP request. body is the raw request body (empty for GET). export function canonicalRequest( method: string, path: string, ts: string, nonce: string, body: Uint8Array, ): Uint8Array { const bodyHashHex = bytesToHex(sha256(body)); return new TextEncoder().encode([method, path, ts, nonce, bodyHashHex].join("\n")); } export interface ControlHeaders { "X-Unibus-Pub": string; "X-Unibus-Ts": string; "X-Unibus-Nonce": string; "X-Unibus-Sig": string; } // signedHeaders builds the transport-auth headers for a control-plane request, // signing canonicalRequest with the user's Ed25519 key. ts/nonce are injected so the // function is deterministic and testable; in production use the current unix seconds // and a fresh 16-byte random nonce (base64). export function signedHeaders( signPub: Uint8Array, signPriv: Uint8Array, method: string, path: string, ts: string, nonce: string, body: Uint8Array, ): ControlHeaders { const sig = signEd25519(signPriv, canonicalRequest(method, path, ts, nonce, body)); return { "X-Unibus-Pub": bytesToHex(signPub), "X-Unibus-Ts": ts, "X-Unibus-Nonce": nonce, "X-Unibus-Sig": bytesToBase64(sig), // base64 standard, matching the Go client }; } // freshNonce returns a base64 (standard) 16-byte random nonce for a live request. export function freshNonce(): string { return bytesToBase64(crypto.getRandomValues(new Uint8Array(16))); }