diff --git a/app.md b/app.md index 1448c0e..7db5496 100644 --- a/app.md +++ b/app.md @@ -2,7 +2,7 @@ name: uniweb lang: go domain: infra -version: 0.1.0 +version: 0.2.0 description: "Frontend web del bus unibus: SPA de chat (React+Mantine) con wallet por usuario (BIP39) + gateway Go (REST+SSE) que actúa de peer del bus para el navegador." tags: [service, messaging, web, frontend, e2e] uses_functions: @@ -118,6 +118,18 @@ programáticos) ve a `unibus`; `uniweb` solo es la capa web encima. ## Capability growth log +- v0.2.0 (2026-06-13) — SDK del bus en TypeScript (`web/src/bus/`), issue 0001 Fase 1: + el protocolo y el cifrado E2E del bus portados al navegador para que `uniweb` deje + de depender del gateway Go. Módulos: `crypto.ts` (Ed25519, ChaCha20-Poly1305, + sealed box con nonce BLAKE2b igual que Go), `frame.ts` (wire format = `encoding/json` + de Go byte a byte), `room.ts` (Policy), `busauth.ts` (nkey NATS + firma de requests + del control-plane), `client.ts` (envelope de room puro + `BusClient` sobre una + interfaz de transporte + cliente HTTP firmado) y `wstransport.ts` (adaptador + `nats.ws`). Paridad cross-language verificada contra vectores Go (`cmd/busvectors`): + **19/19 tests verdes** — endpoint id, firma Ed25519, AEAD, sealed box, frame + marshal/sign, nkey y canonical request. La clave privada del usuario nunca se + serializa hacia la red. La conexión `nats.ws` + control-plane reales se validan en + la Fase 3 (E2E) por requerir un unibus vivo con WebSocket. - v0.1.0 (2026-06-13) — scaffold inicial: extracción de la SPA (`web/`) y el gateway (`cmd/webgw`) desde `unibus` v0.13.0 a su propia app/sub-repo. Sin cambios de capacidad respecto a lo que ya vivía en unibus 0.12.0 (wallet BIP39 + sesiones por usuario); solo diff --git a/web/package.json b/web/package.json index ca5d0b1..faa47fe 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "@noble/hashes": "^2.2.0", "@scure/bip39": "^2.2.0", "@tabler/icons-react": "^3.36.0", + "nats.ws": "^1.30.3", "react": "^19.2.0", "react-dom": "^19.2.0", "tweetnacl": "^1.0.3" diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8cecc4d..eb4586b 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@tabler/icons-react': specifier: ^3.36.0 version: 3.44.0(react@19.2.7) + nats.ws: + specifier: ^1.30.3 + version: 1.30.3 react: specifier: ^19.2.0 version: 19.2.7 @@ -714,6 +717,14 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nats.ws@1.30.3: + resolution: {integrity: sha512-aM77V2SEc+B6lbxCMZK3qfRy4jg8pmHj+wZzQKDiDIQYhLPj6U2NSHHBex0syj72Ayzl4uR5Lp3aKXTaVLbRpw==} + deprecated: 'Package deprecated. Use @nats-io/nats-core or nats.js instead: https://github.com/nats-io/nats.js' + + nkeys.js@1.1.0: + resolution: {integrity: sha512-tB/a0shZL5UZWSwsoeyqfTszONTt4k2YS0tuQioMOD180+MbombYVgzDUYHlx+gejYK6rgf08n/2Df99WY0Sxg==} + engines: {node: '>=10.0.0'} + node-releases@2.0.47: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} @@ -1551,6 +1562,15 @@ snapshots: nanoid@3.3.12: {} + nats.ws@1.30.3: + optionalDependencies: + nkeys.js: 1.1.0 + + nkeys.js@1.1.0: + dependencies: + tweetnacl: 1.0.3 + optional: true + node-releases@2.0.47: {} obug@2.1.3: {} diff --git a/web/src/bus/busauth.test.ts b/web/src/bus/busauth.test.ts new file mode 100644 index 0000000..66d4eff --- /dev/null +++ b/web/src/bus/busauth.test.ts @@ -0,0 +1,41 @@ +// Parity tests for the auth bridge: the browser must produce the same NATS nkey and +// the same signed control-plane request bytes as the Go client, or it would not +// authenticate on either plane (issue 0001, Phase 1). + +import { describe, it, expect } from "vitest"; +import vectors from "./testdata/vectors.json"; +import { hexToBytes, bytesToHex, base64ToBytes } from "./crypto.js"; +import { nkeyPublic, canonicalRequest, signedHeaders } from "./busauth.js"; + +describe("NATS nkey encoding", () => { + it("derives the same user nkey ('U...') as Go from the Ed25519 pubkey", () => { + const v = vectors.nkey; + expect(nkeyPublic(hexToBytes(v.sign_pub_hex))).toBe(v.nkey_public); + }); +}); + +describe("control-plane request signing", () => { + it("builds the same canonical request bytes as Go", () => { + const v = vectors.control_request; + const got = canonicalRequest(v.method, v.path, v.ts, v.nonce, hexToBytes(v.body_hex)); + expect(bytesToHex(got)).toBe(v.canonical_hex); + }); + + it("produces the same Ed25519 signature as Go (X-Unibus-Sig)", () => { + const v = vectors.control_request; + const headers = signedHeaders( + hexToBytes(vectors.sign.sign_pub_hex), + hexToBytes(v.sign_priv_hex), + v.method, + v.path, + v.ts, + v.nonce, + hexToBytes(v.body_hex), + ); + // X-Unibus-Sig is base64-standard; decode and compare hex to the Go vector. + expect(bytesToHex(base64ToBytes(headers["X-Unibus-Sig"]))).toBe(v.sig_hex); + expect(headers["X-Unibus-Pub"]).toBe(vectors.sign.sign_pub_hex); + expect(headers["X-Unibus-Ts"]).toBe(v.ts); + expect(headers["X-Unibus-Nonce"]).toBe(v.nonce); + }); +}); diff --git a/web/src/bus/busauth.ts b/web/src/bus/busauth.ts new file mode 100644 index 0000000..9c36143 --- /dev/null +++ b/web/src/bus/busauth.ts @@ -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))); +} diff --git a/web/src/bus/client.ts b/web/src/bus/client.ts new file mode 100644 index 0000000..19f2469 --- /dev/null +++ b/web/src/bus/client.ts @@ -0,0 +1,303 @@ +// The browser-native bus client, ported from Go pkg/client. It does what the Go +// gateway used to do server-side — only now it runs in the browser, so the user's +// private key never leaves the device (issue 0001). +// +// The module is split so the security-critical part is pure and unit-testable +// without a live server: +// - sealRoomMessage / openRoomMessage: the room ENVELOPE (build a frame, AEAD-seal +// the payload with the room key using the subject as AAD, sign it; and the +// inverse: verify the signature and open the payload). These are pure and pinned +// by tests. +// - NatsTransport: the data-plane transport interface. The concrete WebSocket +// implementation (nats.ws) is thin glue wired and E2E-tested in a later phase. +// - ControlPlane: the signed HTTP client for membershipd (rooms, keys, members). +// - BusClient: orchestrates transport + control plane + envelope. + +import { Policy, Room } from "./room.js"; +import { + Frame, + FrameType, + marshal, + unmarshal, + signingBytes, +} from "./frame.js"; +import { + sealAEAD, + openAEAD, + randomNonce, + signEd25519, + verifyEd25519, + openKeyBox, + endpointID, +} from "./crypto.js"; +import { signedHeaders, freshNonce } from "./busauth.js"; + +// Identity is the user's full cryptographic identity. The private halves stay in +// memory in the browser and are NEVER serialized to the network. +export interface Identity { + signPub: Uint8Array; + signPriv: Uint8Array; // 64-byte Ed25519 (seed||pub) + kexPub: Uint8Array; + kexPriv: Uint8Array; +} + +// --- ULID (message ids), Crockford base32, time-ordered ---------------------- + +const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +export function newULID(nowMs: number = Date.now()): string { + let ts = ""; + let t = nowMs; + for (let i = 0; i < 10; i++) { + ts = CROCKFORD[t % 32] + ts; + t = Math.floor(t / 32); + } + const rnd = crypto.getRandomValues(new Uint8Array(16)); + let r = ""; + for (let i = 0; i < 16; i++) r += CROCKFORD[rnd[i] & 31]; + return ts + r; +} + +// --- room envelope (pure, the security-critical core) ------------------------ + +export interface SealOptions { + type: FrameType; + subject: string; + sender: string; // this peer's endpoint id + signPriv: Uint8Array; + policy: Policy; + epoch: number; + plaintext: Uint8Array; + roomKey?: Uint8Array; // required when policy.encrypt + threadID?: string; + replyTo?: string; + msgID?: string; // defaults to a fresh ULID +} + +// sealRoomMessage builds a wire frame from plaintext exactly as Go's publishFrame: +// for encrypted rooms the payload is ChaCha20-Poly1305-sealed with the room key and +// the SUBJECT as additional authenticated data; for signed rooms an Ed25519 +// signature over the canonical bytes is attached. +export function sealRoomMessage(o: SealOptions): Frame { + const f: Frame = { + type: o.type, + subject: o.subject, + sender: o.sender, + msgID: o.msgID ?? newULID(), + epoch: o.epoch, + threadID: o.threadID, + replyTo: o.replyTo, + }; + if (o.policy.encrypt) { + if (!o.roomKey) throw new Error("sealRoomMessage: encrypted room requires roomKey"); + const nonce = randomNonce(); + f.nonce = nonce; + f.payload = sealAEAD(o.roomKey, nonce, o.plaintext, new TextEncoder().encode(o.subject)); + } else { + f.payload = o.plaintext; + } + if (o.policy.signMsgs) { + f.sig = signEd25519(o.signPriv, signingBytes(f)); + } + return f; +} + +// openRoomMessage is the inverse: it verifies the signature (for signed rooms) and +// opens the AEAD payload (for encrypted rooms), returning the plaintext or null if +// verification/decryption fails (the caller drops the message). +export function openRoomMessage( + f: Frame, + policy: Policy, + signerPub: Uint8Array | undefined, + roomKey: Uint8Array | undefined, +): Uint8Array | null { + if (policy.signMsgs) { + if (!f.sig || !signerPub || !verifyEd25519(f.sig, signingBytes(f), signerPub)) return null; + } + if (policy.encrypt) { + if (!f.nonce || !f.payload || !roomKey) return null; + try { + return openAEAD(roomKey, f.nonce, f.payload, new TextEncoder().encode(f.subject)); + } catch { + return null; + } + } + return f.payload ?? new Uint8Array(0); +} + +// --- data-plane transport ---------------------------------------------------- + +export type MessageHandler = (subject: string, data: Uint8Array) => void; + +// NatsTransport abstracts the NATS data plane so BusClient's logic is testable with +// a mock and the concrete WebSocket transport (nats.ws) stays swappable. The browser +// transport connects over ws(s):// using a NATS nkey authenticator built from the +// user's Ed25519 identity (see busauth.natsAuthenticator). +export interface NatsTransport { + publish(subject: string, data: Uint8Array): void | Promise; + subscribe(subject: string, handler: MessageHandler): Promise; + close(): Promise; +} + +export interface Subscription { + unsubscribe(): void | Promise; +} + +// --- control plane (signed HTTP to membershipd) ------------------------------ + +interface RoomKeyResponse { + sealed_key: string; // base64 sealed box of the room key for this peer + epoch: number; +} + +interface MemberJSON { + endpoint: string; + sign_pub: string; // base64 +} + +// ControlPlane is the signed HTTP client for the membershipd control plane. Every +// request carries the X-Unibus-* auth headers (busauth.signedHeaders). It pins no +// host so it can target any cluster node. +export class ControlPlane { + constructor( + private baseURL: string, + private id: Identity, + ) {} + + private async request(method: string, path: string, body?: unknown): Promise { + const bodyBytes = body === undefined ? new Uint8Array(0) : new TextEncoder().encode(JSON.stringify(body)); + const headers = signedHeaders( + this.id.signPub, + this.id.signPriv, + method, + path, + String(Math.floor(Date.now() / 1000)), + freshNonce(), + bodyBytes, + ); + const init: RequestInit = { method, headers: { ...headers } }; + if (body !== undefined) { + (init.headers as Record)["Content-Type"] = "application/json"; + init.body = bodyBytes; + } + const resp = await fetch(this.baseURL + path, init); + if (!resp.ok) { + let msg = `${method} ${path} -> ${resp.status}`; + try { + const e = await resp.json(); + if (e?.error) msg = `${e.error} (HTTP ${resp.status})`; + } catch { + /* keep the generic message */ + } + throw new Error(`control plane: ${msg}`); + } + return (await resp.json()) as T; + } + + // fetchRoom resolves room metadata (subject, epoch, policy). + fetchRoom(roomID: string): Promise { + return this.request("GET", `/rooms/${roomID}`); + } + + // fetchRoomKey fetches the sealed room key for this peer and opens it with the + // user's X25519 private key. The server only ever stores the key sealed for each + // member, so it cannot read it. + async fetchRoomKey(roomID: string, epoch: number): Promise<{ key: Uint8Array; epoch: number }> { + const q = epoch > 0 ? `&epoch=${epoch}` : ""; + const resp = await this.request( + "GET", + `/rooms/${roomID}/key?endpoint=${endpointID(this.id.signPub)}${q}`, + ); + const sealed = base64ToBytesLocal(resp.sealed_key); + const key = openKeyBox(this.id.kexPub, this.id.kexPriv, sealed); + if (!key) throw new Error("control plane: failed to open room key"); + return { key, epoch: resp.epoch }; + } + + // listMembers returns the room's members keyed by endpoint, so a receiver can find + // a sender's signing public key to verify message signatures. + async signerKeys(roomID: string): Promise> { + const members = await this.request("GET", `/rooms/${roomID}/members`); + const m = new Map(); + for (const member of members) m.set(member.endpoint, base64ToBytesLocal(member.sign_pub)); + return m; + } +} + +// base64ToBytesLocal decodes standard base64 (kept local to avoid widening crypto's +// surface; identical behavior to crypto.base64ToBytes). +function base64ToBytesLocal(s: string): Uint8Array { + const bin = atob(s); + const out = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); + return out; +} + +// --- BusClient --------------------------------------------------------------- + +// BusClient ties the data plane (transport) and control plane together, applying the +// room envelope on publish and subscribe. It holds the user's identity in memory and +// never sends the private key anywhere. +export class BusClient { + private endpoint: string; + private keyCache = new Map>(); // roomID -> epoch -> K + private signCache = new Map>(); // roomID -> endpoint -> signPub + + constructor( + private id: Identity, + private transport: NatsTransport, + private control: ControlPlane, + ) { + this.endpoint = endpointID(id.signPub); + } + + private async roomKey(roomID: string, epoch: number): Promise { + const cached = this.keyCache.get(roomID)?.get(epoch); + if (cached) return cached; + const { key, epoch: ep } = await this.control.fetchRoomKey(roomID, epoch); + let byEpoch = this.keyCache.get(roomID); + if (!byEpoch) { + byEpoch = new Map(); + this.keyCache.set(roomID, byEpoch); + } + byEpoch.set(ep, key); + return key; + } + + // publish seals plaintext per the room policy and publishes it on the data plane. + async publish(roomID: string, plaintext: Uint8Array, opts: { threadID?: string; replyTo?: string; type?: FrameType } = {}): Promise { + const room = await this.control.fetchRoom(roomID); + const roomKey = room.policy.encrypt ? await this.roomKey(roomID, room.epoch) : undefined; + const f = sealRoomMessage({ + type: opts.type ?? FrameType.PUB, + subject: room.subject, + sender: this.endpoint, + signPriv: this.id.signPriv, + policy: room.policy, + epoch: room.epoch, + plaintext, + roomKey, + threadID: opts.threadID, + replyTo: opts.replyTo, + }); + await this.transport.publish(room.subject, marshal(f)); + } + + // subscribe delivers decoded, verified, decrypted messages for a room. Messages + // that fail signature verification or decryption are dropped silently. + async subscribe(roomID: string, handler: (f: Frame, plaintext: Uint8Array) => void): Promise { + const room = await this.control.fetchRoom(roomID); + if (room.policy.signMsgs) await this.loadSigners(roomID); + return this.transport.subscribe(room.subject, async (_subject, data) => { + const f = unmarshal(data); + const signerPub = room.policy.signMsgs ? this.signCache.get(roomID)?.get(f.sender) : undefined; + const roomKey = room.policy.encrypt ? await this.roomKey(roomID, f.epoch) : undefined; + const plaintext = openRoomMessage(f, room.policy, signerPub, roomKey); + if (plaintext) handler(f, plaintext); + }); + } + + private async loadSigners(roomID: string): Promise { + this.signCache.set(roomID, await this.control.signerKeys(roomID)); + } +} diff --git a/web/src/bus/envelope.test.ts b/web/src/bus/envelope.test.ts new file mode 100644 index 0000000..3e5df87 --- /dev/null +++ b/web/src/bus/envelope.test.ts @@ -0,0 +1,80 @@ +// Tests for the room envelope (the security-critical core of the client): sealing a +// message and opening it back, for the encrypted+signed room and the cleartext room, +// plus the failure paths (bad signature, wrong key) that MUST drop the message. + +import { describe, it, expect } from "vitest"; +import { ModeMatrix, ModeNATS } from "./room.js"; +import { FrameType } from "./frame.js"; +import { sealRoomMessage, openRoomMessage } from "./client.js"; +import { endpointID, hexToBytes } from "./crypto.js"; +import vectors from "./testdata/vectors.json"; + +// A deterministic identity from the vectors, so tests do not depend on randomness +// for the keys (the AEAD nonce is still random, which is what we want). +const signPriv = hexToBytes(vectors.sign.sign_priv_hex); +const signPub = hexToBytes(vectors.sign.sign_pub_hex); +const sender = endpointID(signPub); +const roomKey = hexToBytes(vectors.aead.key_hex); + +const utf8 = (s: string) => new TextEncoder().encode(s); +const str = (b: Uint8Array) => new TextDecoder().decode(b); + +describe("room envelope — encrypted + signed (ModeMatrix)", () => { + function seal(plaintext: string) { + return sealRoomMessage({ + type: FrameType.PUB, + subject: "room.parity", + sender, + signPriv, + policy: ModeMatrix, + epoch: 1, + plaintext: utf8(plaintext), + roomKey, + }); + } + + it("round-trips: seal then open recovers the plaintext", () => { + const f = seal("hello e2e"); + expect(f.nonce && f.nonce.length).toBeTruthy(); + expect(f.payload && f.payload.length).toBeTruthy(); + expect(f.sig && f.sig.length).toBeTruthy(); + const opened = openRoomMessage(f, ModeMatrix, signPub, roomKey); + expect(opened).not.toBeNull(); + expect(str(opened!)).toBe("hello e2e"); + }); + + it("drops a message with a tampered signature", () => { + const f = seal("trust me"); + f.sig![0] ^= 0xff; // corrupt the signature + expect(openRoomMessage(f, ModeMatrix, signPub, roomKey)).toBeNull(); + }); + + it("drops a message opened with the wrong room key", () => { + const f = seal("secret"); + const wrongKey = hexToBytes(vectors.keybox.secret_hex); // a different 32-byte key + expect(openRoomMessage(f, ModeMatrix, signPub, wrongKey)).toBeNull(); + }); + + it("ciphertext does not contain the plaintext", () => { + const f = seal("plaintext-marker"); + const wire = new TextDecoder("latin1").decode(f.payload!); + expect(wire.includes("plaintext-marker")).toBe(false); + }); +}); + +describe("room envelope — cleartext (ModeNATS)", () => { + it("carries the payload as-is and opens without a key", () => { + const f = sealRoomMessage({ + type: FrameType.PUB, + subject: "room.clear", + sender, + signPriv, + policy: ModeNATS, + epoch: 0, + plaintext: utf8("in the clear"), + }); + expect(f.sig).toBeUndefined(); + const opened = openRoomMessage(f, ModeNATS, undefined, undefined); + expect(str(opened!)).toBe("in the clear"); + }); +}); diff --git a/web/src/bus/index.ts b/web/src/bus/index.ts new file mode 100644 index 0000000..9010436 --- /dev/null +++ b/web/src/bus/index.ts @@ -0,0 +1,10 @@ +// Public API of the browser-native bus SDK. The SPA imports from here; the internal +// module split (crypto / frame / room / busauth / client / wstransport) stays an +// implementation detail. See issue uniweb/0001. + +export * from "./crypto.js"; +export * from "./frame.js"; +export * from "./room.js"; +export * from "./busauth.js"; +export * from "./client.js"; +export { WsNatsTransport } from "./wstransport.js"; diff --git a/web/src/bus/room.ts b/web/src/bus/room.ts new file mode 100644 index 0000000..784418f --- /dev/null +++ b/web/src/bus/room.ts @@ -0,0 +1,23 @@ +// Room policy and metadata, ported from Go pkg/room. The policy decides how a +// message is treated on the wire: encrypted (AEAD with the room key), persisted +// (durable JetStream history), and/or signed (Ed25519 per message). + +export interface Policy { + encrypt: boolean; // payload is AEAD-encrypted with the room key K + persist: boolean; // messages are kept in durable history (JetStream) + signMsgs: boolean; // each message carries an Ed25519 signature over its canonical bytes +} + +// ModeNATS is a cleartext, ephemeral, unsigned room (the raw NATS behavior). +export const ModeNATS: Policy = { encrypt: false, persist: false, signMsgs: false }; + +// ModeMatrix is the secure default: end-to-end encrypted, persisted, and signed — +// the Matrix-like room the bus uses for real conversations. +export const ModeMatrix: Policy = { encrypt: true, persist: true, signMsgs: true }; + +export interface Room { + id: string; + subject: string; + epoch: number; + policy: Policy; +} diff --git a/web/src/bus/testdata/vectors.json b/web/src/bus/testdata/vectors.json index 38fd349..8f3291e 100644 --- a/web/src/bus/testdata/vectors.json +++ b/web/src/bus/testdata/vectors.json @@ -4,6 +4,10 @@ "sign_pub_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8", "endpoint_id": "Vkdap1RjR0wChd9dvyvKtz2mUTWIOem3dIGy6rEHcIw" }, + "nkey": { + "sign_pub_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8", + "nkey_public": "UAB2CB576PHBBPQ5ODORRZ2LYCMWPZGWGCN2KDK7DXOIMZASKUY3RLKK" + }, "sign": { "sign_priv_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8", "sign_pub_hex": "03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8", @@ -21,7 +25,7 @@ "recipient_kex_pub_hex": "79a631eede1bf9c98f12032cdeadd0e7a079398fc786b88cc846ec89af85a51a", "recipient_kex_priv_hex": "404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f", "secret_hex": "a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf", - "sealed_hex": "9d9a4950543fc7eab770fe2f28f257e4952ba00c6ddf60471bc42cb915b39d70dd04cc50e12a7942a60c3eec1168e89356fee6720c057c937e20f290b0f0050bbc08a75a9877a4d071d09008e0524c2b" + "sealed_hex": "70dfe90c477bac85a758c0c420c36d44e84a8e06434e2344e9e5c730a56e71404a592d37d79aa7c7a997c002160bac6a91c96fb0e6898153348eb19a6d9dc53b5677d40b0c0fdfc47c0b00727a61f04f" }, "frame": { "type": 0, @@ -34,5 +38,15 @@ "wire_b64": "eyJ0IjowLCJzIjoicm9vbS5wYXJpdHkiLCJmcm9tIjoiVmtkYXAxUmpSMHdDaGQ5ZHZ5dkt0ejJtVVRXSU9lbTNkSUd5NnJFSGNJdyIsImlkIjoiMDFIWlkwVkVDVE9SRklYRURVTElEMDAwMSIsImUiOjEsIm4iOiJnSUdDZzRTRmhvZUlpWXFMIiwicCI6Ik1hRmZORFdGdlJneG8xcEQvY2wwNkgxZGRwVnloUEU2SC9xOXVuaitkaXEzNUE9PSIsInNpZyI6IkZOTDFhak0yZFA2c3J5WENyMmoxOVNCVS9rT29MUEpUR2gzNGpuK3pTMVdrV1JPa1ZhTTlXU042WnFrSW1BUjluSGNHYXo4VnJJL3dSMzAyNWFLbkRRPT0ifQ==", "signing_bytes_b64": "eyJ0IjowLCJzIjoicm9vbS5wYXJpdHkiLCJmcm9tIjoiVmtkYXAxUmpSMHdDaGQ5ZHZ5dkt0ejJtVVRXSU9lbTNkSUd5NnJFSGNJdyIsImlkIjoiMDFIWlkwVkVDVE9SRklYRURVTElEMDAwMSIsImUiOjEsIm4iOiJnSUdDZzRTRmhvZUlpWXFMIiwicCI6Ik1hRmZORFdGdlJneG8xcEQvY2wwNkgxZGRwVnloUEU2SC9xOXVuaitkaXEzNUE9PSJ9", "sig_hex": "14d2f56a333674feacaf25c2af68f5f52054fe43a82cf2531a1df88e7fb34b55a45913a455a33d59237a66a90898047d9c77066b3f15ac8ff0477d36e5a2a70d" + }, + "control_request": { + "method": "POST", + "path": "/rooms", + "ts": "1700000000", + "nonce": "Zm9vYmFyMTIzNDU2Nzg5MA==", + "body_hex": "7b227375626a656374223a22726f6f6d2e706172697479227d", + "canonical_hex": "504f53540a2f726f6f6d730a313730303030303030300a5a6d3976596d46794d54497a4e4455324e7a67354d413d3d0a30393038653333663161366261633463363465313938656530613935623532323866383865393337333366323739663038653830336463353931623137643834", + "sig_hex": "1802bd9d6b05b027ed43f0eecdcc831f257065e6e7306e7f0cf8c5db5b07ac57802f6c1e37d4bbc7cc6452d812be644817b908982ba64a455c5e287c6a4c2c0d", + "sign_priv_hex": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f03a107bff3ce10be1d70dd18e74bc09967e4d6309ba50d5f1ddc8664125531b8" } } diff --git a/web/src/bus/wstransport.ts b/web/src/bus/wstransport.ts new file mode 100644 index 0000000..1db0b09 --- /dev/null +++ b/web/src/bus/wstransport.ts @@ -0,0 +1,46 @@ +// Concrete NATS-over-WebSocket transport for the browser, built on nats.ws. This is +// the thin glue between the BusClient logic (which is transport-agnostic and unit- +// tested) and a live NATS server reached over ws(s)://. Because it needs a running +// unibus with the WebSocket listener enabled (issue uniweb/0001, Phase 0), it is +// exercised by the end-to-end tests in Phase 3, not by unit tests. +// +// Note: nats.ws 1.30.x is deprecated upstream in favor of @nats-io/nats-core with a +// WebSocket transport; migrating is tracked as Phase 3 follow-up. The connection +// authenticates with the user's NATS nkey (derived from their Ed25519 identity), so +// the private key signs the server nonce in the browser and never leaves it. + +import { connect, type NatsConnection, type Authenticator } from "nats.ws"; +import type { Identity, NatsTransport, MessageHandler, Subscription } from "./client.js"; +import { natsAuthenticator } from "./busauth.js"; + +export class WsNatsTransport implements NatsTransport { + private constructor(private nc: NatsConnection) {} + + // connect opens a WebSocket connection to one of the given ws(s):// servers, + // authenticating with the user's nkey identity. + static async connect(servers: string[], id: Identity): Promise { + const sign = natsAuthenticator(id.signPub, id.signPriv); + // nats.ws's Authenticator returns the nkey + the base64url signature of the + // server nonce; our natsAuthenticator produces exactly that shape. + const authenticator: Authenticator = (nonce?: string) => sign(nonce ?? ""); + const nc = await connect({ servers, authenticator }); + return new WsNatsTransport(nc); + } + + publish(subject: string, data: Uint8Array): void { + this.nc.publish(subject, data); + } + + async subscribe(subject: string, handler: MessageHandler): Promise { + const sub = this.nc.subscribe(subject, { + callback: (err, msg) => { + if (!err) handler(subject, msg.data); + }, + }); + return { unsubscribe: () => sub.unsubscribe() }; + } + + async close(): Promise { + await this.nc.close(); + } +}