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:
476
sdks/typescript/demo/index.html
Normal file
476
sdks/typescript/demo/index.html
Normal 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 & 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>
|
||||
Reference in New Issue
Block a user