From 28ceaaf072af2dd1e79d35ea61119c1cb3a3fe98 Mon Sep 17 00:00:00 2001 From: Christian Nennemann Date: Wed, 4 Mar 2026 01:28:38 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Sprint=208=20=E2=80=94=20TypeScript=20S?= =?UTF-8?q?DK=20with=20WASM=20crypto=20and=20browser=20demo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- sdks/typescript/.gitignore | 3 + sdks/typescript/README.md | 68 +++ sdks/typescript/demo/index.html | 476 +++++++++++++++ sdks/typescript/package-lock.json | 29 + sdks/typescript/package.json | 15 + sdks/typescript/src/client.ts | 258 +++++++++ sdks/typescript/src/crypto.ts | 135 +++++ sdks/typescript/src/index.ts | 46 ++ sdks/typescript/src/transport.ts | 206 +++++++ sdks/typescript/src/types.ts | 58 ++ sdks/typescript/tsconfig.json | 14 + sdks/typescript/wasm-crypto/Cargo.lock | 773 +++++++++++++++++++++++++ sdks/typescript/wasm-crypto/Cargo.toml | 21 + sdks/typescript/wasm-crypto/src/lib.rs | 162 ++++++ 14 files changed, 2264 insertions(+) create mode 100644 sdks/typescript/.gitignore create mode 100644 sdks/typescript/README.md create mode 100644 sdks/typescript/demo/index.html create mode 100644 sdks/typescript/package-lock.json create mode 100644 sdks/typescript/package.json create mode 100644 sdks/typescript/src/client.ts create mode 100644 sdks/typescript/src/crypto.ts create mode 100644 sdks/typescript/src/index.ts create mode 100644 sdks/typescript/src/transport.ts create mode 100644 sdks/typescript/src/types.ts create mode 100644 sdks/typescript/tsconfig.json create mode 100644 sdks/typescript/wasm-crypto/Cargo.lock create mode 100644 sdks/typescript/wasm-crypto/Cargo.toml create mode 100644 sdks/typescript/wasm-crypto/src/lib.rs 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

+
+ + Not loaded +
+
+ + +
+

Ed25519 Identity

+
+ + +
+
+
+ Alice +
--
+
+
+ Bob +
--
+
+
+
+ + +
+

Safety Number

+ +
--
+
+ + +
+

Sign & Verify

+ +
+ + +
+
--
+
+ + +
+

Hybrid Encryption (X25519 + ML-KEM-768)

+ +
+ + + +
+
--
+
+ + +
+

Sealed Sender

+ +
+ + +
+
--
+
+ + +
+

Message Padding

+ +
+ + +
+
--
+
+ + +
+

Server Connection

+ +
+ + + 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

+ + +
+ + +
+
+
+ + + + 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}"))) +}