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:
3
sdks/typescript/.gitignore
vendored
Normal file
3
sdks/typescript/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
wasm-crypto/target/
|
||||
68
sdks/typescript/README.md
Normal file
68
sdks/typescript/README.md
Normal 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
|
||||
```
|
||||
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>
|
||||
29
sdks/typescript/package-lock.json
generated
Normal file
29
sdks/typescript/package-lock.json
generated
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
sdks/typescript/package.json
Normal file
15
sdks/typescript/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
258
sdks/typescript/src/client.ts
Normal file
258
sdks/typescript/src/client.ts
Normal 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;
|
||||
}
|
||||
135
sdks/typescript/src/crypto.ts
Normal file
135
sdks/typescript/src/crypto.ts
Normal 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);
|
||||
}
|
||||
46
sdks/typescript/src/index.ts
Normal file
46
sdks/typescript/src/index.ts
Normal 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";
|
||||
206
sdks/typescript/src/transport.ts
Normal file
206
sdks/typescript/src/transport.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
58
sdks/typescript/src/types.ts
Normal file
58
sdks/typescript/src/types.ts
Normal 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 };
|
||||
14
sdks/typescript/tsconfig.json
Normal file
14
sdks/typescript/tsconfig.json
Normal 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
773
sdks/typescript/wasm-crypto/Cargo.lock
generated
Normal 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"
|
||||
21
sdks/typescript/wasm-crypto/Cargo.toml
Normal file
21
sdks/typescript/wasm-crypto/Cargo.toml
Normal 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
|
||||
162
sdks/typescript/wasm-crypto/src/lib.rs
Normal file
162
sdks/typescript/wasm-crypto/src/lib.rs
Normal 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}")))
|
||||
}
|
||||
Reference in New Issue
Block a user