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

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

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

View File

@@ -0,0 +1,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>