feat: Sprint 8 — TypeScript SDK with WASM crypto and browser demo

- WASM crypto bundle (175KB): 13 wasm_bindgen functions wrapping
  quicproquo-core's Ed25519 identity, X25519+ML-KEM-768 hybrid KEM,
  safety numbers, sealed sender, and message padding
- @quicproquo/client TypeScript SDK: QpqClient class with connect,
  health, resolveUser, createChannel, send/sendWithTTL, receive,
  deleteAccount; WebSocket transport with request/response correlation
  and reconnection; ergonomic crypto.ts wrapper over WASM functions
- Browser demo: vanilla HTML page with interactive crypto operations
  (identity gen, safety numbers, sign/verify, hybrid encrypt/decrypt,
  sealed sender, padding) and chat UI for server connectivity
- Offline mode: crypto operations work without server connection

TypeScript strict mode, 0 errors. WASM bundle size optimized (lto + opt-level=s).
This commit is contained in:
2026-03-04 01:28:38 +01:00
parent 65ff26235e
commit 28ceaaf072
14 changed files with 2264 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
/**
* High-level quicproquo client.
*
* Combines the WASM crypto layer with a WebSocket transport to provide
* a type-safe API for interacting with a quicproquo server.
*
* The crypto helpers work standalone (no server connection required).
* Server RPC methods require a WebSocket bridge proxy since the native
* server speaks Cap'n Proto over QUIC/TCP + Noise_XX.
*/
import type {
ConnectOptions,
Message,
ChannelResult,
Identity,
HybridKeypair,
TransportEvent,
} from "./types.js";
import type { Transport } from "./transport.js";
import { WebSocketTransport } from "./transport.js";
import * as crypto from "./crypto.js";
export class QpqClient {
private transport: Transport | null;
private token: Uint8Array | null = null;
private constructor(transport: Transport | null) {
this.transport = transport;
}
/**
* Connect to a quicproquo server via WebSocket bridge.
*
* NOTE: The server must expose a WebSocket-to-capnp bridge endpoint.
* See the project documentation for bridge proxy setup.
*/
static async connect(opts: ConnectOptions): Promise<QpqClient> {
await crypto.initCrypto();
const transport = await WebSocketTransport.connect(opts);
return new QpqClient(transport);
}
/**
* Create a client for offline/crypto-only usage (no server connection).
* All crypto helper methods will work; RPC methods will throw.
*/
static async offline(): Promise<QpqClient> {
await crypto.initCrypto();
return new QpqClient(null);
}
/** Register a handler for transport-level events. */
on(handler: (event: TransportEvent) => void): void {
if (this.transport) {
this.transport.on(handler);
}
}
/** True when the transport is connected. */
get connected(): boolean {
return this.transport?.connected ?? false;
}
/** Set a session token for authenticated RPC calls. */
setSessionToken(token: Uint8Array): void {
this.token = token;
}
// -----------------------------------------------------------------------
// RPC methods (require server connection via WebSocket bridge)
// -----------------------------------------------------------------------
/** Health check. */
async health(): Promise<string> {
const resp = await this.rpc("health", {});
return resp.result as string;
}
/** Resolve a username to its public key. */
async resolveUser(username: string): Promise<Uint8Array> {
const resp = await this.rpc("resolveUser", { username });
return base64ToBytes(resp.result as string);
}
/** Create or join a channel with a peer. */
async createChannel(peerKey: Uint8Array): Promise<ChannelResult> {
const resp = await this.rpc("createChannel", {
peerKey: bytesToBase64(peerKey),
});
const r = resp.result as { channelId: string; wasNew: boolean };
return {
channelId: base64ToBytes(r.channelId),
wasNew: r.wasNew,
};
}
/** Send a payload to a recipient. Returns the message sequence number. */
async send(recipientKey: Uint8Array, payload: Uint8Array): Promise<number> {
const resp = await this.rpc("send", {
recipientKey: bytesToBase64(recipientKey),
payload: bytesToBase64(payload),
});
return resp.result as number;
}
/** Send a payload with a time-to-live in seconds. */
async sendWithTTL(
recipientKey: Uint8Array,
payload: Uint8Array,
ttlSecs: number,
): Promise<number> {
const resp = await this.rpc("send", {
recipientKey: bytesToBase64(recipientKey),
payload: bytesToBase64(payload),
ttlSecs,
});
return resp.result as number;
}
/** Receive pending messages for a recipient key. */
async receive(recipientKey: Uint8Array): Promise<Message[]> {
const resp = await this.rpc("receive", {
recipientKey: bytesToBase64(recipientKey),
});
const raw = resp.result as Array<{ seq: number; data: string }>;
return raw.map((m) => ({
seq: m.seq,
data: base64ToBytes(m.data),
}));
}
/** Delete the authenticated account. */
async deleteAccount(): Promise<void> {
await this.rpc("deleteAccount", {});
}
// -----------------------------------------------------------------------
// Crypto helpers (work offline, delegate to WASM)
// -----------------------------------------------------------------------
/** Generate a fresh Ed25519 identity. */
generateIdentity(): Identity {
return crypto.generateIdentity();
}
/** Derive public key from an Ed25519 seed. */
identityPublicKey(seed: Uint8Array): Uint8Array {
return crypto.identityPublicKey(seed);
}
/** Sign a message with an Ed25519 seed. */
sign(seed: Uint8Array, message: Uint8Array): Uint8Array {
return crypto.sign(seed, message);
}
/** Verify an Ed25519 signature. */
verify(
publicKey: Uint8Array,
message: Uint8Array,
signature: Uint8Array,
): boolean {
return crypto.verify(publicKey, message, signature);
}
/** Generate a hybrid (X25519 + ML-KEM-768) keypair. */
hybridGenerateKeypair(): HybridKeypair {
return crypto.hybridGenerateKeypair();
}
/** Encrypt to a hybrid public key. */
hybridEncrypt(publicKey: Uint8Array, plaintext: Uint8Array): Uint8Array {
return crypto.hybridEncrypt(publicKey, plaintext);
}
/** Decrypt a hybrid envelope. */
hybridDecrypt(keypairBlob: Uint8Array, envelope: Uint8Array): Uint8Array {
return crypto.hybridDecrypt(keypairBlob, envelope);
}
/** Compute a 60-digit safety number from two public keys. */
computeSafetyNumber(keyA: Uint8Array, keyB: Uint8Array): string {
return crypto.computeSafetyNumber(keyA, keyB);
}
/** Seal a payload in a sealed sender envelope. */
seal(seed: Uint8Array, payload: Uint8Array): Uint8Array {
return crypto.seal(seed, payload);
}
/** Unseal a sealed sender envelope. */
unseal(envelope: Uint8Array): Uint8Array {
return crypto.unseal(envelope);
}
/** Pad a message to a fixed bucket size. */
padMessage(data: Uint8Array): Uint8Array {
return crypto.padMessage(data);
}
/** Remove padding from a message. */
unpadMessage(data: Uint8Array): Uint8Array {
return crypto.unpadMessage(data);
}
/** Gracefully close the connection. */
close(): void {
if (this.transport) {
this.transport.close();
this.transport = null;
}
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
private async rpc(
method: string,
params: Record<string, unknown>,
): Promise<{ ok: true; result: unknown }> {
if (!this.transport) {
throw new Error(
`Cannot call RPC method "${method}": no server connection. ` +
"Use QpqClient.connect() instead of QpqClient.offline().",
);
}
if (this.token) {
params.token = bytesToBase64(this.token);
}
const resp = await this.transport.call(method, params);
if (!resp.ok) {
throw new Error(`RPC error in ${method}: ${resp.error ?? "unknown"}`);
}
return resp as { ok: true; result: unknown };
}
}
// ---------------------------------------------------------------------------
// Base64 helpers (browser-compatible, no Node.js Buffer dependency)
// ---------------------------------------------------------------------------
function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
function base64ToBytes(b64: string): Uint8Array {
const binary = atob(b64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}

View File

@@ -0,0 +1,135 @@
/**
* Ergonomic TypeScript wrapper around the qpq WASM crypto primitives.
*
* Call `initCrypto()` once before using any other function.
*/
import init, * as wasm from "../pkg/qpq_wasm_crypto.js";
import type { Identity, HybridKeypair } from "./types.js";
let initialized = false;
/** Initialize the WASM module. Safe to call multiple times. */
export async function initCrypto(): Promise<void> {
if (!initialized) {
await init();
initialized = true;
}
}
/** Returns true if the WASM module has been initialized. */
export function isCryptoReady(): boolean {
return initialized;
}
// ---------------------------------------------------------------------------
// Ed25519 identity
// ---------------------------------------------------------------------------
/** Generate a fresh Ed25519 identity (32-byte seed + 32-byte public key). */
export function generateIdentity(): Identity {
const seed = wasm.generate_identity();
const publicKey = wasm.identity_public_key(seed);
return { seed, publicKey };
}
/** Derive the 32-byte Ed25519 public key from a 32-byte seed. */
export function identityPublicKey(seed: Uint8Array): Uint8Array {
return wasm.identity_public_key(seed);
}
/** Sign a message with an Ed25519 seed. Returns the 64-byte signature. */
export function sign(seed: Uint8Array, message: Uint8Array): Uint8Array {
return wasm.sign(seed, message);
}
/** Verify an Ed25519 signature. Returns true if valid. */
export function verify(
publicKey: Uint8Array,
message: Uint8Array,
signature: Uint8Array,
): boolean {
return wasm.verify(publicKey, message, signature);
}
// ---------------------------------------------------------------------------
// Hybrid KEM (X25519 + ML-KEM-768)
// ---------------------------------------------------------------------------
/** Generate a hybrid keypair. Private: 2432 bytes, public: 1216 bytes. */
export function hybridGenerateKeypair(): HybridKeypair {
const blob = wasm.hybrid_generate_keypair();
const publicKey = wasm.hybrid_public_key(blob);
return { blob, publicKey };
}
/** Extract the 1216-byte public key from a hybrid keypair blob. */
export function hybridPublicKey(keypairBlob: Uint8Array): Uint8Array {
return wasm.hybrid_public_key(keypairBlob);
}
/** Encrypt plaintext to a hybrid public key. */
export function hybridEncrypt(
publicKey: Uint8Array,
plaintext: Uint8Array,
): Uint8Array {
return wasm.hybrid_encrypt(publicKey, plaintext);
}
/** Decrypt a hybrid envelope using the keypair blob. */
export function hybridDecrypt(
keypairBlob: Uint8Array,
envelope: Uint8Array,
): Uint8Array {
return wasm.hybrid_decrypt(keypairBlob, envelope);
}
// ---------------------------------------------------------------------------
// Safety number
// ---------------------------------------------------------------------------
/**
* Compute a 60-digit safety number from two 32-byte Ed25519 public keys.
* Returns 12 space-separated 5-digit groups.
* Symmetric: computeSafetyNumber(a, b) === computeSafetyNumber(b, a).
*/
export function computeSafetyNumber(
keyA: Uint8Array,
keyB: Uint8Array,
): string {
return wasm.compute_safety_number(keyA, keyB);
}
// ---------------------------------------------------------------------------
// Sealed sender
// ---------------------------------------------------------------------------
/**
* Wrap a payload in a sealed sender envelope.
* (magic || sender public key || Ed25519 signature || payload)
*/
export function seal(seed: Uint8Array, payload: Uint8Array): Uint8Array {
return wasm.seal(seed, payload);
}
/**
* Unseal a sealed sender envelope.
* Returns sender_public_key(32) || inner_payload.
*/
export function unseal(envelope: Uint8Array): Uint8Array {
return wasm.unseal(envelope);
}
// ---------------------------------------------------------------------------
// Message padding
// ---------------------------------------------------------------------------
/** Pad a message to a fixed bucket size (256, 1024, 4096, or 16384 bytes). */
export function padMessage(data: Uint8Array): Uint8Array {
return wasm.pad_message(data);
}
/** Remove padding and recover the original message. */
export function unpadMessage(data: Uint8Array): Uint8Array {
return wasm.unpad_message(data);
}

View File

@@ -0,0 +1,46 @@
/**
* @quicproquo/client -- TypeScript SDK for quicproquo E2E encrypted messenger.
*
* The SDK provides:
* - WASM-powered crypto (Ed25519, hybrid X25519+ML-KEM-768, sealed sender)
* - A high-level QpqClient class for server RPC via WebSocket bridge
* - Standalone crypto operations that work without a server connection
*/
// Client
export { QpqClient } from "./client.js";
// Transport
export { WebSocketTransport } from "./transport.js";
export type { Transport } from "./transport.js";
// Crypto (direct access for advanced usage)
export {
initCrypto,
isCryptoReady,
generateIdentity,
identityPublicKey,
sign,
verify,
hybridGenerateKeypair,
hybridPublicKey,
hybridEncrypt,
hybridDecrypt,
computeSafetyNumber,
seal,
unseal,
padMessage,
unpadMessage,
} from "./crypto.js";
// Types
export type {
ConnectOptions,
Message,
ChannelResult,
Identity,
HybridKeypair,
RpcRequest,
RpcResponse,
TransportEvent,
} from "./types.js";

View File

@@ -0,0 +1,206 @@
/**
* Transport abstraction for communicating with a quicproquo server.
*
* The native qpq server speaks Cap'n Proto RPC over QUIC/TCP + Noise_XX.
* Browsers cannot open raw TCP sockets, so this SDK assumes a WebSocket
* bridge proxy that translates JSON-over-WebSocket to capnp RPC.
*
* The transport layer handles:
* - Binary/text message framing over WebSocket
* - Request/response correlation via incrementing IDs
* - Automatic reconnection with exponential back-off
*/
import type {
ConnectOptions,
RpcRequest,
RpcResponse,
TransportEvent,
} from "./types.js";
// ---------------------------------------------------------------------------
// Transport interface
// ---------------------------------------------------------------------------
export interface Transport {
/** Send an RPC request and wait for the correlated response. */
call(method: string, params: Record<string, unknown>): Promise<RpcResponse>;
/** Register a listener for transport-level events. */
on(handler: (event: TransportEvent) => void): void;
/** True when the underlying connection is open. */
readonly connected: boolean;
/** Gracefully close the transport. */
close(): void;
}
// ---------------------------------------------------------------------------
// WebSocketTransport
// ---------------------------------------------------------------------------
const DEFAULT_CONNECT_TIMEOUT = 5_000;
const DEFAULT_REQUEST_TIMEOUT = 10_000;
const MAX_RECONNECT_DELAY = 30_000;
interface PendingCall {
resolve: (r: RpcResponse) => void;
reject: (e: Error) => void;
timer: ReturnType<typeof setTimeout>;
}
export class WebSocketTransport implements Transport {
private ws: WebSocket | null = null;
private nextId = 1;
private pending = new Map<number, PendingCall>();
private handlers: Array<(event: TransportEvent) => void> = [];
private _connected = false;
private reconnectDelay = 1_000;
private shouldReconnect = true;
private readonly opts: ConnectOptions;
private constructor(opts: ConnectOptions) {
this.opts = opts;
}
get connected(): boolean {
return this._connected;
}
/** Create and connect a WebSocketTransport. Resolves when the socket opens. */
static connect(opts: ConnectOptions): Promise<WebSocketTransport> {
const transport = new WebSocketTransport(opts);
return transport.doConnect();
}
on(handler: (event: TransportEvent) => void): void {
this.handlers.push(handler);
}
async call(
method: string,
params: Record<string, unknown>,
): Promise<RpcResponse> {
if (!this._connected || !this.ws) {
throw new Error("Transport is not connected");
}
const id = this.nextId++;
const request: RpcRequest = { id, method, params };
return new Promise<RpcResponse>((resolve, reject) => {
const timeout = this.opts.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT;
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`RPC timeout for ${method} (id=${id})`));
}, timeout);
this.pending.set(id, { resolve, reject, timer });
this.ws!.send(JSON.stringify(request));
});
}
close(): void {
this.shouldReconnect = false;
if (this.ws) {
this.ws.close(1000, "client closing");
this.ws = null;
}
this.rejectAll("Transport closed");
}
// -----------------------------------------------------------------------
// Internal
// -----------------------------------------------------------------------
private doConnect(): Promise<WebSocketTransport> {
return new Promise<WebSocketTransport>((resolve, reject) => {
const timeout = this.opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT;
const timer = setTimeout(() => {
reject(new Error(`Connection timeout after ${timeout}ms`));
if (this.ws) {
this.ws.close();
this.ws = null;
}
}, timeout);
const ws = new WebSocket(this.opts.addr);
ws.binaryType = "arraybuffer";
this.ws = ws;
ws.addEventListener("open", () => {
clearTimeout(timer);
this._connected = true;
this.reconnectDelay = 1_000;
this.emit({ type: "open" });
resolve(this);
});
ws.addEventListener("close", (ev) => {
clearTimeout(timer);
this._connected = false;
this.emit({ type: "close", code: ev.code, reason: ev.reason });
this.rejectAll(`WebSocket closed: ${ev.code} ${ev.reason}`);
this.maybeReconnect();
});
ws.addEventListener("error", () => {
const err = new Error("WebSocket error");
this.emit({ type: "error", error: err });
});
ws.addEventListener("message", (ev) => {
this.handleMessage(ev.data);
});
});
}
private handleMessage(raw: unknown): void {
// Binary frames are forwarded as-is
if (raw instanceof ArrayBuffer) {
this.emit({ type: "message", data: new Uint8Array(raw) });
return;
}
// Text frames are parsed as JSON RPC responses
if (typeof raw === "string") {
let resp: RpcResponse;
try {
resp = JSON.parse(raw) as RpcResponse;
} catch {
return; // ignore malformed
}
const pending = this.pending.get(resp.id);
if (pending) {
clearTimeout(pending.timer);
this.pending.delete(resp.id);
pending.resolve(resp);
}
}
}
private emit(event: TransportEvent): void {
for (const h of this.handlers) {
h(event);
}
}
private rejectAll(reason: string): void {
for (const [id, pending] of this.pending) {
clearTimeout(pending.timer);
pending.reject(new Error(reason));
this.pending.delete(id);
}
}
private maybeReconnect(): void {
if (!this.shouldReconnect) return;
const delay = Math.min(this.reconnectDelay, MAX_RECONNECT_DELAY);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_RECONNECT_DELAY);
setTimeout(() => {
if (this.shouldReconnect) {
this.doConnect().catch(() => {
/* reconnect attempt failed, will retry via close handler */
});
}
}, delay);
}
}

View File

@@ -0,0 +1,58 @@
/** Options for connecting to a quicproquo server. */
export interface ConnectOptions {
/** WebSocket URL, e.g. ws://host:port or wss://host:port */
addr: string;
/** Skip TLS certificate verification (development only). */
insecureSkipVerify?: boolean;
/** Timeout in milliseconds for the initial connection. Default: 5000. */
connectTimeoutMs?: number;
/** Timeout in milliseconds for RPC request/response. Default: 10000. */
requestTimeoutMs?: number;
}
/** A message received from the server. */
export interface Message {
seq: number;
data: Uint8Array;
}
/** Result of creating or joining a channel. */
export interface ChannelResult {
channelId: Uint8Array;
wasNew: boolean;
}
/** An Ed25519 identity (seed + derived public key). */
export interface Identity {
seed: Uint8Array;
publicKey: Uint8Array;
}
/** A hybrid (X25519 + ML-KEM-768) keypair. */
export interface HybridKeypair {
/** Full keypair blob: private(2432) || public(1216). */
blob: Uint8Array;
publicKey: Uint8Array;
}
/** Internal RPC request envelope. */
export interface RpcRequest {
id: number;
method: string;
params: Record<string, unknown>;
}
/** Internal RPC response envelope. */
export interface RpcResponse {
id: number;
ok: boolean;
result?: unknown;
error?: string;
}
/** Events emitted by the transport layer. */
export type TransportEvent =
| { type: "open" }
| { type: "close"; code: number; reason: string }
| { type: "error"; error: Error }
| { type: "message"; data: Uint8Array };