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

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

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

3
sdks/typescript/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules/
dist/
wasm-crypto/target/

68
sdks/typescript/README.md Normal file
View File

@@ -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
```

View File

@@ -0,0 +1,476 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>quicproquo -- Browser Crypto Demo</title>
<style>
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #e6edf3;
--muted: #8b949e;
--accent: #58a6ff;
--green: #3fb950;
--red: #f85149;
--mono: 'SF Mono', 'Cascadia Code', 'Fira Code', 'Consolas', monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding: 1.5rem;
max-width: 960px;
margin: 0 auto;
}
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; }
h2 { font-size: 1.1rem; margin-bottom: 0.5rem; color: var(--accent); }
.subtitle { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.5rem; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.status { font-family: var(--mono); font-size: 0.85rem; }
.ok { color: var(--green); }
.err { color: var(--red); }
.info { color: var(--muted); }
button {
background: var(--accent);
color: #fff;
border: none;
border-radius: 6px;
padding: 0.4rem 0.8rem;
cursor: pointer;
font-size: 0.85rem;
margin: 0.25rem 0.25rem 0.25rem 0;
}
button:hover { opacity: 0.9; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
pre {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.5rem;
font-family: var(--mono);
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
margin-top: 0.5rem;
max-height: 200px;
overflow-y: auto;
}
input[type="text"] {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
padding: 0.35rem 0.5rem;
font-size: 0.85rem;
width: 100%;
margin-bottom: 0.5rem;
}
input[type="text"]:focus { outline: 1px solid var(--accent); }
.row { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
#log {
max-height: 300px;
overflow-y: auto;
}
.log-line { font-family: var(--mono); font-size: 0.8rem; padding: 0.1rem 0; }
.bridge-note {
background: #1c1f26;
border-left: 3px solid var(--accent);
padding: 0.6rem 0.8rem;
font-size: 0.85rem;
margin-top: 0.5rem;
color: var(--muted);
}
</style>
</head>
<body>
<h1>quicproquo</h1>
<p class="subtitle">E2E Encrypted Messenger -- Browser Crypto Demo</p>
<!-- WASM Init -->
<div class="card">
<h2>WASM Module</h2>
<div class="row">
<button id="btn-init">Initialize WASM</button>
<span id="wasm-status" class="status info">Not loaded</span>
</div>
</div>
<!-- Identity -->
<div class="card">
<h2>Ed25519 Identity</h2>
<div class="row">
<button id="btn-gen-alice" disabled>Generate Alice</button>
<button id="btn-gen-bob" disabled>Generate Bob</button>
</div>
<div class="grid" style="margin-top:0.5rem;">
<div>
<strong>Alice</strong>
<pre id="alice-info">--</pre>
</div>
<div>
<strong>Bob</strong>
<pre id="bob-info">--</pre>
</div>
</div>
</div>
<!-- Safety Number -->
<div class="card">
<h2>Safety Number</h2>
<button id="btn-safety" disabled>Compute Safety Number</button>
<pre id="safety-output">--</pre>
</div>
<!-- Sign / Verify -->
<div class="card">
<h2>Sign &amp; Verify</h2>
<input type="text" id="sign-msg" placeholder="Message to sign (Alice's key)" value="Hello, quicproquo!">
<div class="row">
<button id="btn-sign" disabled>Sign (Alice)</button>
<button id="btn-verify" disabled>Verify (Alice pubkey)</button>
</div>
<pre id="sign-output">--</pre>
</div>
<!-- Hybrid Encryption -->
<div class="card">
<h2>Hybrid Encryption (X25519 + ML-KEM-768)</h2>
<input type="text" id="encrypt-msg" placeholder="Plaintext to encrypt" value="Post-quantum secrets!">
<div class="row">
<button id="btn-hybrid-gen" disabled>Generate Hybrid Keypair</button>
<button id="btn-hybrid-enc" disabled>Encrypt</button>
<button id="btn-hybrid-dec" disabled>Decrypt</button>
</div>
<pre id="hybrid-output">--</pre>
</div>
<!-- Sealed Sender -->
<div class="card">
<h2>Sealed Sender</h2>
<input type="text" id="seal-msg" placeholder="Payload to seal" value="Anonymous message">
<div class="row">
<button id="btn-seal" disabled>Seal (Alice)</button>
<button id="btn-unseal" disabled>Unseal</button>
</div>
<pre id="seal-output">--</pre>
</div>
<!-- Message Padding -->
<div class="card">
<h2>Message Padding</h2>
<input type="text" id="pad-msg" placeholder="Message to pad" value="Short">
<div class="row">
<button id="btn-pad" disabled>Pad</button>
<button id="btn-unpad" disabled>Unpad</button>
</div>
<pre id="pad-output">--</pre>
</div>
<!-- Server Connect -->
<div class="card">
<h2>Server Connection</h2>
<input type="text" id="server-addr" placeholder="ws://localhost:9000" value="ws://localhost:9000">
<div class="row">
<button id="btn-connect" disabled>Connect</button>
<button id="btn-disconnect" disabled>Disconnect</button>
<span id="conn-status" class="status info">Disconnected</span>
</div>
<div class="bridge-note">
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.
</div>
</div>
<!-- Chat -->
<div class="card">
<h2>Chat</h2>
<input type="text" id="chat-user" placeholder="Recipient username">
<input type="text" id="chat-msg" placeholder="Message">
<div class="row">
<button id="btn-send" disabled>Send</button>
<button id="btn-recv" disabled>Receive</button>
</div>
<div id="log"></div>
</div>
<script type="module">
import init, * as wasm from '../pkg/qpq_wasm_crypto.js';
// State
let wasmReady = false;
let aliceSeed = null, alicePub = null;
let bobSeed = null, bobPub = null;
let lastSignature = null;
let hybridKeypair = null, hybridPub = null;
let lastEnvelope = null;
let lastSealed = null;
let lastPadded = null;
let ws = null;
// Helpers
const $ = (id) => document.getElementById(id);
const hex = (bytes) => Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
const trunc = (s, n = 64) => s.length > n ? s.slice(0, n) + '...' : s;
function log(msg, cls = '') {
const el = document.createElement('div');
el.className = 'log-line' + (cls ? ' ' + cls : '');
el.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
$('log').prepend(el);
}
function enableCryptoButtons() {
const ids = [
'btn-gen-alice', 'btn-gen-bob', 'btn-safety', 'btn-sign', 'btn-verify',
'btn-hybrid-gen', 'btn-hybrid-enc', 'btn-hybrid-dec',
'btn-seal', 'btn-unseal', 'btn-pad', 'btn-unpad', 'btn-connect'
];
ids.forEach(id => $(id).disabled = false);
}
// -- WASM Init --
$('btn-init').addEventListener('click', async () => {
try {
$('wasm-status').textContent = 'Loading...';
$('wasm-status').className = 'status info';
await init();
wasmReady = true;
$('wasm-status').textContent = 'Loaded (174 KB WASM)';
$('wasm-status').className = 'status ok';
$('btn-init').disabled = true;
enableCryptoButtons();
} catch (e) {
$('wasm-status').textContent = 'Error: ' + e.message;
$('wasm-status').className = 'status err';
}
});
// -- Identity Generation --
$('btn-gen-alice').addEventListener('click', () => {
aliceSeed = wasm.generate_identity();
alicePub = wasm.identity_public_key(aliceSeed);
$('alice-info').textContent =
`seed: ${trunc(hex(aliceSeed))}\npubkey: ${trunc(hex(alicePub))}`;
});
$('btn-gen-bob').addEventListener('click', () => {
bobSeed = wasm.generate_identity();
bobPub = wasm.identity_public_key(bobSeed);
$('bob-info').textContent =
`seed: ${trunc(hex(bobSeed))}\npubkey: ${trunc(hex(bobPub))}`;
});
// -- Safety Number --
$('btn-safety').addEventListener('click', () => {
if (!alicePub || !bobPub) {
$('safety-output').textContent = 'Generate both Alice and Bob first.';
return;
}
const sn = wasm.compute_safety_number(alicePub, bobPub);
$('safety-output').textContent = sn;
});
// -- Sign / Verify --
$('btn-sign').addEventListener('click', () => {
if (!aliceSeed) {
$('sign-output').textContent = 'Generate Alice first.';
return;
}
const msg = new TextEncoder().encode($('sign-msg').value);
lastSignature = wasm.sign(aliceSeed, msg);
$('sign-output').textContent =
`Signature (64 bytes): ${trunc(hex(lastSignature), 80)}`;
});
$('btn-verify').addEventListener('click', () => {
if (!alicePub || !lastSignature) {
$('sign-output').textContent = 'Sign a message first.';
return;
}
const msg = new TextEncoder().encode($('sign-msg').value);
const valid = wasm.verify(alicePub, msg, lastSignature);
$('sign-output').textContent += `\nVerification: ${valid ? 'VALID' : 'INVALID'}`;
});
// -- Hybrid Encryption --
$('btn-hybrid-gen').addEventListener('click', () => {
const kp = wasm.hybrid_generate_keypair();
hybridKeypair = kp;
hybridPub = wasm.hybrid_public_key(kp);
$('hybrid-output').textContent =
`Keypair: ${kp.length} bytes (private: 2432 + public: 1216)\n` +
`Public key: ${trunc(hex(hybridPub), 80)}`;
});
$('btn-hybrid-enc').addEventListener('click', () => {
if (!hybridPub) {
$('hybrid-output').textContent = 'Generate a hybrid keypair first.';
return;
}
const plaintext = new TextEncoder().encode($('encrypt-msg').value);
lastEnvelope = wasm.hybrid_encrypt(hybridPub, plaintext);
$('hybrid-output').textContent =
`Encrypted envelope: ${lastEnvelope.length} bytes\n` +
`Ciphertext: ${trunc(hex(lastEnvelope), 80)}`;
});
$('btn-hybrid-dec').addEventListener('click', () => {
if (!hybridKeypair || !lastEnvelope) {
$('hybrid-output').textContent = 'Encrypt a message first.';
return;
}
const decrypted = wasm.hybrid_decrypt(hybridKeypair, lastEnvelope);
const text = new TextDecoder().decode(decrypted);
$('hybrid-output').textContent += `\nDecrypted: "${text}"`;
});
// -- Sealed Sender --
$('btn-seal').addEventListener('click', () => {
if (!aliceSeed) {
$('seal-output').textContent = 'Generate Alice first.';
return;
}
const payload = new TextEncoder().encode($('seal-msg').value);
lastSealed = wasm.seal(aliceSeed, payload);
$('seal-output').textContent =
`Sealed envelope: ${lastSealed.length} bytes\n` +
`Data: ${trunc(hex(lastSealed), 80)}`;
});
$('btn-unseal').addEventListener('click', () => {
if (!lastSealed) {
$('seal-output').textContent = 'Seal a message first.';
return;
}
const result = wasm.unseal(lastSealed);
const senderKey = result.slice(0, 32);
const innerPayload = result.slice(32);
const text = new TextDecoder().decode(innerPayload);
$('seal-output').textContent +=
`\nSender pubkey: ${trunc(hex(senderKey))}\n` +
`Inner payload: "${text}"`;
});
// -- Message Padding --
$('btn-pad').addEventListener('click', () => {
const msg = new TextEncoder().encode($('pad-msg').value);
lastPadded = wasm.pad_message(msg);
$('pad-output').textContent =
`Original: ${msg.length} bytes -> Padded: ${lastPadded.length} bytes\n` +
`Bucket sizes: 256, 1024, 4096, 16384`;
});
$('btn-unpad').addEventListener('click', () => {
if (!lastPadded) {
$('pad-output').textContent = 'Pad a message first.';
return;
}
const recovered = wasm.unpad_message(lastPadded);
const text = new TextDecoder().decode(recovered);
$('pad-output').textContent += `\nUnpadded: "${text}" (${recovered.length} bytes)`;
});
// -- Server Connection --
$('btn-connect').addEventListener('click', () => {
const addr = $('server-addr').value;
if (!addr) return;
try {
$('conn-status').textContent = 'Connecting...';
$('conn-status').className = 'status info';
ws = new WebSocket(addr);
ws.binaryType = 'arraybuffer';
ws.addEventListener('open', () => {
$('conn-status').textContent = 'Connected';
$('conn-status').className = 'status ok';
$('btn-disconnect').disabled = false;
$('btn-send').disabled = false;
$('btn-recv').disabled = false;
log('Connected to ' + addr, 'ok');
});
ws.addEventListener('close', (ev) => {
$('conn-status').textContent = `Disconnected (${ev.code})`;
$('conn-status').className = 'status info';
$('btn-disconnect').disabled = true;
$('btn-send').disabled = true;
$('btn-recv').disabled = true;
log('Disconnected: ' + ev.reason);
});
ws.addEventListener('error', () => {
$('conn-status').textContent = 'Connection error';
$('conn-status').className = 'status err';
log('WebSocket error -- is the bridge proxy running?', 'err');
});
ws.addEventListener('message', (ev) => {
if (typeof ev.data === 'string') {
try {
const resp = JSON.parse(ev.data);
log('Server: ' + JSON.stringify(resp));
} catch {
log('Server (raw): ' + ev.data);
}
} else {
log('Server (binary): ' + new Uint8Array(ev.data).length + ' bytes');
}
});
} catch (e) {
$('conn-status').textContent = 'Error: ' + e.message;
$('conn-status').className = 'status err';
}
});
$('btn-disconnect').addEventListener('click', () => {
if (ws) {
ws.close(1000, 'user disconnect');
ws = null;
}
});
// -- Chat --
let rpcId = 1;
function sendRpc(method, params) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
log('Not connected', 'err');
return;
}
const req = { id: rpcId++, method, params };
ws.send(JSON.stringify(req));
log('Sent: ' + method + ' (id=' + req.id + ')');
}
$('btn-send').addEventListener('click', () => {
const user = $('chat-user').value;
const msg = $('chat-msg').value;
if (!user || !msg) return;
sendRpc('send', { recipient: user, message: msg });
});
$('btn-recv').addEventListener('click', () => {
const user = $('chat-user').value;
if (!user) {
log('Enter a recipient username first', 'info');
return;
}
sendRpc('receive', { recipient: user });
});
</script>
</body>
</html>

29
sdks/typescript/package-lock.json generated Normal file
View File

@@ -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"
}
}
}
}

View File

@@ -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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/**/*"]
}

773
sdks/typescript/wasm-crypto/Cargo.lock generated Normal file
View File

@@ -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"

View File

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

View File

@@ -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<u8> {
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<Vec<u8>, 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<Vec<u8>, 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<bool, JsError> {
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<u8> {
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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<String, JsError> {
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<Vec<u8>, 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<Vec<u8>, 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<u8> {
quicproquo_core::padding::pad(data)
}
/// Remove padding and recover the original message.
#[wasm_bindgen]
pub fn unpad_message(data: &[u8]) -> Result<Vec<u8>, JsError> {
quicproquo_core::padding::unpad(data)
.map_err(|e| JsError::new(&format!("unpad failed: {e}")))
}