Files
uniweb/web/src/bus/busauth.ts
T
agent b72976e06c feat(bus): complete TypeScript SDK — auth, room envelope, client, transport
Second half of the browser-native bus SDK (issue 0001, Phase 1), making uniweb a
peer of the bus in its own right (like unibus_android) without the Go gateway:

- busauth.ts: NATS user nkey from the Ed25519 key (base32 + crc16, no nkeys dep)
  and control-plane request signing (CanonicalRequest + X-Unibus-* headers).
- room.ts: Policy / Room types (ModeNATS, ModeMatrix).
- client.ts: the pure room ENVELOPE (sealRoomMessage/openRoomMessage — AEAD with
  the subject as AAD, Ed25519 sign, drop on verify/decrypt failure), a transport-
  agnostic BusClient, and a signed ControlPlane HTTP client (fetch room/key/members,
  open the sealed room key locally).
- wstransport.ts: concrete nats.ws WebSocket transport (validated E2E in Phase 3).
- index.ts: public SDK surface.

Parity pinned by vectors from unibus cmd/busvectors (extended with nkey + signed
control-request vectors): 19/19 green. The user's private key signs everything in
the browser and is never sent to any server. Bumps uniweb to 0.2.0.

Remaining for Phase 1 completion: the live nats.ws connection + control-plane,
which need a running unibus with the WebSocket listener — exercised in Phase 3.
2026-06-13 22:54:54 +02:00

138 lines
5.1 KiB
TypeScript

// 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)));
}