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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+20
@@ -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: {}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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)));
|
||||
}
|
||||
@@ -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<void>;
|
||||
subscribe(subject: string, handler: MessageHandler): Promise<Subscription>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
export interface Subscription {
|
||||
unsubscribe(): void | Promise<void>;
|
||||
}
|
||||
|
||||
// --- 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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||
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<string, string>)["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<Room> {
|
||||
return this.request<Room>("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<RoomKeyResponse>(
|
||||
"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<Map<string, Uint8Array>> {
|
||||
const members = await this.request<MemberJSON[]>("GET", `/rooms/${roomID}/members`);
|
||||
const m = new Map<string, Uint8Array>();
|
||||
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<string, Map<number, Uint8Array>>(); // roomID -> epoch -> K
|
||||
private signCache = new Map<string, Map<string, Uint8Array>>(); // 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<Uint8Array> {
|
||||
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<void> {
|
||||
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<Subscription> {
|
||||
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<void> {
|
||||
this.signCache.set(roomID, await this.control.signerKeys(roomID));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+15
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WsNatsTransport> {
|
||||
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<Subscription> {
|
||||
const sub = this.nc.subscribe(subject, {
|
||||
callback: (err, msg) => {
|
||||
if (!err) handler(subject, msg.data);
|
||||
},
|
||||
});
|
||||
return { unsubscribe: () => sub.unsubscribe() };
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.nc.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user