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.
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
// 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)));
|
||||
}
|
||||
Reference in New Issue
Block a user