diff --git a/sdks/typescript/.gitignore b/sdks/typescript/.gitignore
new file mode 100644
index 0000000..680fc89
--- /dev/null
+++ b/sdks/typescript/.gitignore
@@ -0,0 +1,3 @@
+node_modules/
+dist/
+wasm-crypto/target/
diff --git a/sdks/typescript/README.md b/sdks/typescript/README.md
new file mode 100644
index 0000000..083c9fd
--- /dev/null
+++ b/sdks/typescript/README.md
@@ -0,0 +1,68 @@
+# @quicproquo/client
+
+TypeScript SDK for [quicproquo](https://github.com/nicholasgasior/quicproquo) --
+an E2E encrypted group messenger built on MLS (RFC 9420), hybrid post-quantum
+key exchange (X25519 + ML-KEM-768), and sealed sender envelopes.
+
+## Features
+
+- **WASM-powered crypto** -- Ed25519 signatures, hybrid KEM, sealed sender,
+ message padding, safety numbers -- all compiled from the Rust `quicproquo-core`
+ crate via `wasm-pack`.
+- **High-level client API** -- `QpqClient` wraps transport + crypto into a
+ type-safe interface for resolving users, creating channels, and exchanging
+ messages.
+- **Offline mode** -- All crypto operations work without a server connection.
+ Use `QpqClient.offline()` for key generation, signing, encryption, etc.
+- **Transport abstraction** -- Pluggable `Transport` interface with a built-in
+ `WebSocketTransport` for browser environments.
+
+## Quick start
+
+```typescript
+import { QpqClient } from "@quicproquo/client";
+
+// Crypto-only (no server needed)
+const client = await QpqClient.offline();
+const alice = client.generateIdentity();
+const bob = client.generateIdentity();
+const safetyNumber = client.computeSafetyNumber(alice.publicKey, bob.publicKey);
+console.log("Safety number:", safetyNumber);
+
+// Sign and verify
+const msg = new TextEncoder().encode("hello");
+const sig = client.sign(alice.seed, msg);
+console.log("Valid:", client.verify(alice.publicKey, msg, sig));
+```
+
+## Server connection
+
+The native qpq server speaks Cap'n Proto RPC over QUIC/TCP with Noise_XX.
+Browsers cannot open raw TCP sockets, so a WebSocket bridge proxy is required
+for full server connectivity:
+
+```typescript
+const client = await QpqClient.connect({ addr: "wss://bridge.example.com" });
+const peerKey = await client.resolveUser("bob");
+const channel = await client.createChannel(peerKey);
+```
+
+## Building
+
+```bash
+npm install
+npm run build # compiles to dist/
+```
+
+## Project structure
+
+```
+src/
+ index.ts -- public API exports
+ client.ts -- QpqClient class (high-level API)
+ transport.ts -- Transport interface + WebSocket implementation
+ crypto.ts -- WASM crypto wrapper
+ types.ts -- TypeScript type definitions
+pkg/ -- WASM output (built by wasm-pack)
+demo/ -- Browser demo page
+```
diff --git a/sdks/typescript/demo/index.html b/sdks/typescript/demo/index.html
new file mode 100644
index 0000000..ae8e99a
--- /dev/null
+++ b/sdks/typescript/demo/index.html
@@ -0,0 +1,476 @@
+
+
+
+
+
+quicproquo -- Browser Crypto Demo
+
+
+
+
+quicproquo
+E2E Encrypted Messenger -- Browser Crypto Demo
+
+
+
+
WASM Module
+
+ Initialize WASM
+ Not loaded
+
+
+
+
+
+
Ed25519 Identity
+
+ Generate Alice
+ Generate Bob
+
+
+
+
+
+
+
Safety Number
+
Compute Safety Number
+
--
+
+
+
+
+
Sign & Verify
+
+
+ Sign (Alice)
+ Verify (Alice pubkey)
+
+
--
+
+
+
+
+
Hybrid Encryption (X25519 + ML-KEM-768)
+
+
+ Generate Hybrid Keypair
+ Encrypt
+ Decrypt
+
+
--
+
+
+
+
+
Sealed Sender
+
+
+ Seal (Alice)
+ Unseal
+
+
--
+
+
+
+
+
Message Padding
+
+
+ Pad
+ Unpad
+
+
--
+
+
+
+
+
Server Connection
+
+
+ Connect
+ Disconnect
+ Disconnected
+
+
+ The native qpq server speaks Cap'n Proto RPC over QUIC/TCP + Noise_XX.
+ A WebSocket-to-capnp bridge proxy is required for browser connectivity.
+ This demo's transport layer sends JSON-framed requests over WebSocket.
+
+
+
+
+
+
Chat
+
+
+
+ Send
+ Receive
+
+
+
+
+
+
+
diff --git a/sdks/typescript/package-lock.json b/sdks/typescript/package-lock.json
new file mode 100644
index 0000000..2fc24a2
--- /dev/null
+++ b/sdks/typescript/package-lock.json
@@ -0,0 +1,29 @@
+{
+ "name": "@quicproquo/client",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@quicproquo/client",
+ "version": "0.1.0",
+ "devDependencies": {
+ "typescript": "^5.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ }
+ }
+}
diff --git a/sdks/typescript/package.json b/sdks/typescript/package.json
new file mode 100644
index 0000000..43bddab
--- /dev/null
+++ b/sdks/typescript/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "@quicproquo/client",
+ "version": "0.1.0",
+ "type": "module",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "scripts": {
+ "build": "tsc",
+ "clean": "rm -rf dist"
+ },
+ "dependencies": {},
+ "devDependencies": {
+ "typescript": "^5.0.0"
+ }
+}
diff --git a/sdks/typescript/src/client.ts b/sdks/typescript/src/client.ts
new file mode 100644
index 0000000..f767f98
--- /dev/null
+++ b/sdks/typescript/src/client.ts
@@ -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 {
+ 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 {
+ 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 {
+ const resp = await this.rpc("health", {});
+ return resp.result as string;
+ }
+
+ /** Resolve a username to its public key. */
+ async resolveUser(username: string): Promise {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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,
+ ): 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;
+}
diff --git a/sdks/typescript/src/crypto.ts b/sdks/typescript/src/crypto.ts
new file mode 100644
index 0000000..8f767a6
--- /dev/null
+++ b/sdks/typescript/src/crypto.ts
@@ -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 {
+ 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);
+}
diff --git a/sdks/typescript/src/index.ts b/sdks/typescript/src/index.ts
new file mode 100644
index 0000000..a1b3e1e
--- /dev/null
+++ b/sdks/typescript/src/index.ts
@@ -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";
diff --git a/sdks/typescript/src/transport.ts b/sdks/typescript/src/transport.ts
new file mode 100644
index 0000000..6085e9f
--- /dev/null
+++ b/sdks/typescript/src/transport.ts
@@ -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): Promise;
+ /** 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;
+}
+
+export class WebSocketTransport implements Transport {
+ private ws: WebSocket | null = null;
+ private nextId = 1;
+ private pending = new Map();
+ 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 {
+ const transport = new WebSocketTransport(opts);
+ return transport.doConnect();
+ }
+
+ on(handler: (event: TransportEvent) => void): void {
+ this.handlers.push(handler);
+ }
+
+ async call(
+ method: string,
+ params: Record,
+ ): Promise {
+ 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((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 {
+ return new Promise((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);
+ }
+}
diff --git a/sdks/typescript/src/types.ts b/sdks/typescript/src/types.ts
new file mode 100644
index 0000000..a729ff7
--- /dev/null
+++ b/sdks/typescript/src/types.ts
@@ -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;
+}
+
+/** 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 };
diff --git a/sdks/typescript/tsconfig.json b/sdks/typescript/tsconfig.json
new file mode 100644
index 0000000..01bb758
--- /dev/null
+++ b/sdks/typescript/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "declaration": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true
+ },
+ "include": ["src/**/*"]
+}
diff --git a/sdks/typescript/wasm-crypto/Cargo.lock b/sdks/typescript/wasm-crypto/Cargo.lock
new file mode 100644
index 0000000..dbe15ed
--- /dev/null
+++ b/sdks/typescript/wasm-crypto/Cargo.lock
@@ -0,0 +1,773 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aead"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
+dependencies = [
+ "crypto-common",
+ "generic-array",
+]
+
+[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+]
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "chacha20"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818"
+dependencies = [
+ "cfg-if",
+ "cipher",
+ "cpufeatures",
+]
+
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
+[[package]]
+name = "ciborium"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e"
+dependencies = [
+ "ciborium-io",
+ "ciborium-ll",
+ "serde",
+]
+
+[[package]]
+name = "ciborium-io"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757"
+
+[[package]]
+name = "ciborium-ll"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9"
+dependencies = [
+ "ciborium-io",
+ "half",
+]
+
+[[package]]
+name = "cipher"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
+dependencies = [
+ "crypto-common",
+ "inout",
+ "zeroize",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "typenum",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "curve25519-dalek-derive",
+ "digest",
+ "fiat-crypto",
+ "rustc_version",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "curve25519-dalek-derive"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "zeroize",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "ed25519"
+version = "2.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+dependencies = [
+ "pkcs8",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-dalek"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+dependencies = [
+ "curve25519-dalek",
+ "ed25519",
+ "rand_core",
+ "serde",
+ "sha2",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "fiat-crypto"
+version = "0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "zerocopy",
+]
+
+[[package]]
+name = "hkdf"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "hybrid-array"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2d35805454dc9f8662a98d6d61886ffe26bd465f5960e0e55345c70d5c0d2a9"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "inout"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "js-sys"
+version = "0.3.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "keccak"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
+dependencies = [
+ "cpufeatures",
+]
+
+[[package]]
+name = "kem"
+version = "0.3.0-pre.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b8645470337db67b01a7f966decf7d0bafedbae74147d33e641c67a91df239f"
+dependencies = [
+ "rand_core",
+ "zeroize",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.182"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
+
+[[package]]
+name = "memchr"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
+
+[[package]]
+name = "ml-kem"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de49b3df74c35498c0232031bb7e85f9389f913e2796169c8ab47a53993a18f"
+dependencies = [
+ "hybrid-array",
+ "kem",
+ "rand_core",
+ "sha3",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "opaque-debug"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
+
+[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "poly1305"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf"
+dependencies = [
+ "cpufeatures",
+ "opaque-debug",
+ "universal-hash",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "qpq-wasm-crypto"
+version = "0.1.0"
+dependencies = [
+ "js-sys",
+ "quicproquo-core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "quicproquo-core"
+version = "0.1.0"
+dependencies = [
+ "argon2",
+ "chacha20poly1305",
+ "ciborium",
+ "ed25519-dalek",
+ "getrandom",
+ "hkdf",
+ "hmac",
+ "ml-kem",
+ "rand",
+ "serde",
+ "serde_json",
+ "sha2",
+ "thiserror",
+ "x25519-dalek",
+ "zeroize",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "semver"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sha3"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60"
+dependencies = [
+ "digest",
+ "keccak",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "rand_core",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "typenum"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "universal-hash"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
+dependencies = [
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "x25519-dalek"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
+dependencies = [
+ "curve25519-dalek",
+ "rand_core",
+ "serde",
+ "zeroize",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+dependencies = [
+ "serde",
+ "zeroize_derive",
+]
+
+[[package]]
+name = "zeroize_derive"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/sdks/typescript/wasm-crypto/Cargo.toml b/sdks/typescript/wasm-crypto/Cargo.toml
new file mode 100644
index 0000000..c0377e1
--- /dev/null
+++ b/sdks/typescript/wasm-crypto/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "qpq-wasm-crypto"
+version = "0.1.0"
+edition = "2021"
+description = "WASM bindings for quicproquo crypto primitives"
+license = "MIT"
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+wasm-bindgen = "0.2"
+quicproquo-core = { path = "../../../crates/quicproquo-core", default-features = false }
+js-sys = "0.3"
+
+# Standalone crate — not part of the workspace.
+[workspace]
+
+[profile.release]
+opt-level = "s"
+lto = true
diff --git a/sdks/typescript/wasm-crypto/src/lib.rs b/sdks/typescript/wasm-crypto/src/lib.rs
new file mode 100644
index 0000000..125e497
--- /dev/null
+++ b/sdks/typescript/wasm-crypto/src/lib.rs
@@ -0,0 +1,162 @@
+//! WASM bindings for quicproquo crypto primitives.
+//!
+//! Exposes Ed25519 identity keys, hybrid X25519+ML-KEM-768, safety numbers,
+//! sealed sender envelopes, and message padding to JavaScript via wasm-bindgen.
+
+use wasm_bindgen::prelude::*;
+
+// ── Identity (Ed25519) ──────────────────────────────────────────────────────
+
+/// Generate a fresh Ed25519 identity keypair. Returns the 32-byte seed.
+#[wasm_bindgen]
+pub fn generate_identity() -> Vec {
+ let kp = quicproquo_core::IdentityKeypair::generate();
+ kp.seed_bytes().to_vec()
+}
+
+/// Derive the 32-byte Ed25519 public key from a 32-byte seed.
+#[wasm_bindgen]
+pub fn identity_public_key(seed: &[u8]) -> Result, JsError> {
+ let seed: [u8; 32] = seed
+ .try_into()
+ .map_err(|_| JsError::new("seed must be exactly 32 bytes"))?;
+ let kp = quicproquo_core::IdentityKeypair::from_seed(seed);
+ Ok(kp.public_key_bytes().to_vec())
+}
+
+/// Sign a message with an Ed25519 seed. Returns the 64-byte signature.
+#[wasm_bindgen]
+pub fn sign(seed: &[u8], message: &[u8]) -> Result, JsError> {
+ let seed: [u8; 32] = seed
+ .try_into()
+ .map_err(|_| JsError::new("seed must be exactly 32 bytes"))?;
+ let kp = quicproquo_core::IdentityKeypair::from_seed(seed);
+ Ok(kp.sign_raw(message).to_vec())
+}
+
+/// Verify an Ed25519 signature. Returns true if valid.
+#[wasm_bindgen]
+pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result {
+ let pk: [u8; 32] = public_key
+ .try_into()
+ .map_err(|_| JsError::new("public key must be exactly 32 bytes"))?;
+ let sig: [u8; 64] = signature
+ .try_into()
+ .map_err(|_| JsError::new("signature must be exactly 64 bytes"))?;
+ match quicproquo_core::IdentityKeypair::verify_raw(&pk, message, &sig) {
+ Ok(()) => Ok(true),
+ Err(_) => Ok(false),
+ }
+}
+
+// ── Hybrid KEM (X25519 + ML-KEM-768) ───────────────────────────────────────
+
+/// Generate a hybrid keypair. Returns serialized bytes:
+/// private_key(2432) || public_key(1216).
+///
+/// Private key layout: x25519_sk(32) || mlkem_dk(2400)
+/// Public key layout: x25519_pk(32) || mlkem_ek(1184)
+#[wasm_bindgen]
+pub fn hybrid_generate_keypair() -> Vec {
+ let kp = quicproquo_core::HybridKeypair::generate();
+ let private_bytes = kp.private_to_bytes(); // 2432 bytes
+ let public_bytes = kp.public_key().to_bytes(); // 1216 bytes
+ let mut out = Vec::with_capacity(private_bytes.len() + public_bytes.len());
+ out.extend_from_slice(&private_bytes);
+ out.extend_from_slice(&public_bytes);
+ out
+}
+
+/// Extract the public key (1216 bytes) from a hybrid keypair blob.
+#[wasm_bindgen]
+pub fn hybrid_public_key(keypair_blob: &[u8]) -> Result, JsError> {
+ // Private key is 2432 bytes, public key starts at offset 2432
+ if keypair_blob.len() != 2432 + 1216 {
+ return Err(JsError::new("keypair blob must be exactly 3648 bytes"));
+ }
+ Ok(keypair_blob[2432..].to_vec())
+}
+
+/// Encrypt plaintext to a hybrid public key (1216 bytes).
+/// Returns the encrypted envelope.
+#[wasm_bindgen]
+pub fn hybrid_encrypt(public_key: &[u8], plaintext: &[u8]) -> Result, JsError> {
+ let pk = quicproquo_core::HybridPublicKey::from_bytes(public_key)
+ .map_err(|e| JsError::new(&format!("invalid hybrid public key: {e}")))?;
+ quicproquo_core::hybrid_encrypt(&pk, plaintext, b"", b"")
+ .map_err(|e| JsError::new(&format!("encryption failed: {e}")))
+}
+
+/// Decrypt a hybrid envelope using the private key portion (first 2432 bytes of keypair blob).
+#[wasm_bindgen]
+pub fn hybrid_decrypt(keypair_blob: &[u8], envelope: &[u8]) -> Result, JsError> {
+ // Accept either full blob (3648) or just private key (2432)
+ let private_bytes = if keypair_blob.len() == 2432 + 1216 {
+ &keypair_blob[..2432]
+ } else if keypair_blob.len() == 2432 {
+ keypair_blob
+ } else {
+ return Err(JsError::new(
+ "keypair must be 2432 (private only) or 3648 (full blob) bytes",
+ ));
+ };
+ let kp = quicproquo_core::HybridKeypair::from_private_bytes(private_bytes)
+ .map_err(|e| JsError::new(&format!("invalid hybrid private key: {e}")))?;
+ quicproquo_core::hybrid_decrypt(&kp, envelope, b"", b"")
+ .map_err(|e| JsError::new(&format!("decryption failed: {e}")))
+}
+
+// ── Safety numbers ──────────────────────────────────────────────────────────
+
+/// Compute a 60-digit safety number from two 32-byte Ed25519 public keys.
+/// Returns a string of 12 space-separated 5-digit groups.
+/// Result is symmetric: compute_safety_number(a, b) === compute_safety_number(b, a).
+#[wasm_bindgen]
+pub fn compute_safety_number(key_a: &[u8], key_b: &[u8]) -> Result {
+ let a: [u8; 32] = key_a
+ .try_into()
+ .map_err(|_| JsError::new("key_a must be exactly 32 bytes"))?;
+ let b: [u8; 32] = key_b
+ .try_into()
+ .map_err(|_| JsError::new("key_b must be exactly 32 bytes"))?;
+ Ok(quicproquo_core::safety_numbers::compute_safety_number(&a, &b))
+}
+
+// ── Sealed sender ───────────────────────────────────────────────────────────
+
+/// Wrap a payload in a sealed sender envelope (magic + sender key + Ed25519 signature + payload).
+/// `seed` is the 32-byte Ed25519 identity seed.
+#[wasm_bindgen]
+pub fn seal(seed: &[u8], payload: &[u8]) -> Result, JsError> {
+ let seed: [u8; 32] = seed
+ .try_into()
+ .map_err(|_| JsError::new("seed must be exactly 32 bytes"))?;
+ let kp = quicproquo_core::IdentityKeypair::from_seed(seed);
+ Ok(quicproquo_core::sealed_sender::seal(&kp, payload))
+}
+
+/// Unseal a sealed sender envelope. Returns sender_public_key(32) || inner_payload.
+#[wasm_bindgen]
+pub fn unseal(envelope: &[u8]) -> Result, JsError> {
+ let (sender_key, inner) = quicproquo_core::sealed_sender::unseal(envelope)
+ .map_err(|e| JsError::new(&format!("unseal failed: {e}")))?;
+ let mut out = Vec::with_capacity(32 + inner.len());
+ out.extend_from_slice(&sender_key);
+ out.extend_from_slice(&inner);
+ Ok(out)
+}
+
+// ── Padding ─────────────────────────────────────────────────────────────────
+
+/// Pad a message to a fixed bucket size (256, 1024, 4096, or 16384 bytes).
+#[wasm_bindgen]
+pub fn pad_message(data: &[u8]) -> Vec {
+ quicproquo_core::padding::pad(data)
+}
+
+/// Remove padding and recover the original message.
+#[wasm_bindgen]
+pub fn unpad_message(data: &[u8]) -> Result, JsError> {
+ quicproquo_core::padding::unpad(data)
+ .map_err(|e| JsError::new(&format!("unpad failed: {e}")))
+}