WASM bridge: web client now uses same crypto as CLI (full interop)

warzone-wasm crate:
- Compiles warzone-protocol to WebAssembly via wasm-pack
- Exposes WasmIdentity, WasmSession, decrypt_wire_message to JS
- Same X25519 + ChaCha20-Poly1305 + X3DH + Double Ratchet as CLI
- 344KB WASM binary (optimized with wasm-opt)

WireMessage moved to warzone-protocol:
- Shared type used by CLI client, WASM bridge, and TUI
- Guarantees identical bincode serialization across all clients

Web client rewritten:
- Loads WASM module on startup (/wasm/warzone_wasm.js)
- Identity: WasmIdentity generates same key types as CLI
- Registration: sends bincode PreKeyBundle (same format as CLI)
- Encrypt: WasmSession.encrypt/encrypt_key_exchange
- Decrypt: decrypt_wire_message (handles KeyExchange + Message)
- Sessions persisted in localStorage (base64 ratchet state)
- Groups: per-member WASM encryption (interop with CLI members)

Server routes:
- GET /wasm/warzone_wasm.js — serves WASM JS glue
- GET /wasm/warzone_wasm_bg.wasm — serves WASM binary
- Both embedded at compile time via include_str!/include_bytes!

Web ↔ CLI interop now works:
- Same key exchange (X3DH with X25519)
- Same ratchet (Double Ratchet with ChaCha20-Poly1305)
- Same wire format (bincode WireMessage)
- Web user can message CLI user and vice versa

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 08:37:58 +04:00
parent d7b71efdbc
commit 40ea631283
9 changed files with 494 additions and 180 deletions

View File

@@ -1,5 +1,6 @@
use axum::{
response::Html,
http::header,
response::{Html, IntoResponse},
routing::get,
Router,
};
@@ -7,13 +8,30 @@ use axum::{
use crate::state::AppState;
pub fn routes() -> Router<AppState> {
Router::new().route("/", get(web_ui))
Router::new()
.route("/", get(web_ui))
.route("/wasm/warzone_wasm.js", get(wasm_js))
.route("/wasm/warzone_wasm_bg.wasm", get(wasm_binary))
}
async fn web_ui() -> Html<&'static str> {
Html(WEB_HTML)
}
async fn wasm_js() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "application/javascript")],
include_str!("../../../../wasm-pkg/warzone_wasm.js"),
)
}
async fn wasm_binary() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "application/wasm")],
include_bytes!("../../../../wasm-pkg/warzone_wasm_bg.wasm").as_slice(),
)
}
const WEB_HTML: &str = r##"<!DOCTYPE html>
<html lang="en">
<head>
@@ -125,114 +143,65 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
</div>
</div>
<script>
<script type="module">
import init, { WasmIdentity, WasmSession, decrypt_wire_message } from '/wasm/warzone_wasm.js';
const SERVER = window.location.origin;
const $messages = document.getElementById('messages');
const $input = document.getElementById('msg-input');
const $peerInput = document.getElementById('peer-input');
// ── State ──
let seed = null; // Uint8Array(32)
let myKeyPair = null; // ECDH CryptoKeyPair
let wasmIdentity = null; // WasmIdentity from WASM
let myFingerprint = '';
let derivedKeys = {}; // peerFP -> AES CryptoKey
let mySeedHex = '';
let sessions = {}; // peerFP -> { session: WasmSession, data: base64 }
let peerBundles = {}; // peerFP -> bundle bytes
let pollTimer = null;
// ── Crypto ──
function toHex(buf) {
return Array.from(new Uint8Array(buf)).map(b => b.toString(16).padStart(2, '0')).join('');
}
function fromHex(h) {
const clean = h.replace(/[^0-9a-fA-F]/g, '');
const bytes = new Uint8Array(clean.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(clean.substr(i*2, 2), 16);
return bytes;
}
function formatFP(bytes) {
const h = toHex(bytes);
return h.match(/.{4}/g).join(':');
}
let wasmReady = false;
function normFP(fp) {
return fp.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
}
async function generateKeyPair() {
return crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
// ── WASM-based crypto (same as CLI: X25519 + ChaCha20 + Double Ratchet) ──
async function initWasm() {
await init('/wasm/warzone_wasm_bg.wasm');
wasmReady = true;
}
async function computeFingerprint(publicKey) {
const raw = await crypto.subtle.exportKey('raw', publicKey);
const hash = await crypto.subtle.digest('SHA-256', raw);
return new Uint8Array(hash).slice(0, 16);
}
async function deriveAESKey(theirPubJwk) {
const theirPub = await crypto.subtle.importKey('jwk', theirPubJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, []);
const bits = await crypto.subtle.deriveBits({ name: 'ECDH', public: theirPub }, myKeyPair.privateKey, 256);
return crypto.subtle.importKey('raw', bits, 'AES-GCM', false, ['encrypt', 'decrypt']);
}
async function aesEncrypt(key, plaintext) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(plaintext));
// iv(12) + ciphertext
const result = new Uint8Array(12 + ct.byteLength);
result.set(iv, 0);
result.set(new Uint8Array(ct), 12);
return result;
}
async function aesDecrypt(key, data) {
const iv = data.slice(0, 12);
const ct = data.slice(12);
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
return new TextDecoder().decode(plain);
}
// ── Identity ──
async function initIdentity(seedBytes) {
seed = seedBytes;
myKeyPair = await generateKeyPair();
const fpBytes = await computeFingerprint(myKeyPair.publicKey);
myFingerprint = formatFP(fpBytes);
// Persist
const privJwk = await crypto.subtle.exportKey('jwk', myKeyPair.privateKey);
const pubJwk = await crypto.subtle.exportKey('jwk', myKeyPair.publicKey);
localStorage.setItem('wz-seed', toHex(seed));
localStorage.setItem('wz-priv', JSON.stringify(privJwk));
localStorage.setItem('wz-pub', JSON.stringify(pubJwk));
function initIdentityFromSeed(hexSeed) {
wasmIdentity = WasmIdentity.from_hex_seed(hexSeed);
myFingerprint = wasmIdentity.fingerprint();
mySeedHex = wasmIdentity.seed_hex();
localStorage.setItem('wz-seed', mySeedHex);
localStorage.setItem('wz-fp', myFingerprint);
}
async function loadIdentity() {
const savedSeed = localStorage.getItem('wz-seed');
const savedPriv = localStorage.getItem('wz-priv');
const savedPub = localStorage.getItem('wz-pub');
const savedFP = localStorage.getItem('wz-fp');
if (!savedSeed || !savedPriv || !savedPub || !savedFP) return false;
seed = fromHex(savedSeed);
const privJwk = JSON.parse(savedPriv);
const pubJwk = JSON.parse(savedPub);
const priv = await crypto.subtle.importKey('jwk', privJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
const pub_ = await crypto.subtle.importKey('jwk', pubJwk, { name: 'ECDH', namedCurve: 'P-256' }, true, []);
myKeyPair = { privateKey: priv, publicKey: pub_ };
myFingerprint = savedFP;
return true;
function generateNewIdentity() {
wasmIdentity = new WasmIdentity();
myFingerprint = wasmIdentity.fingerprint();
mySeedHex = wasmIdentity.seed_hex();
localStorage.setItem('wz-seed', mySeedHex);
localStorage.setItem('wz-fp', myFingerprint);
}
// ── Server API ──
function loadSavedIdentity() {
const saved = localStorage.getItem('wz-seed');
if (!saved) return false;
try {
initIdentityFromSeed(saved);
return true;
} catch(e) {
localStorage.removeItem('wz-seed');
return false;
}
}
async function registerKey() {
const pubJwk = await crypto.subtle.exportKey('jwk', myKeyPair.publicKey);
const fp = normFP(myFingerprint);
const bundleBytes = new TextEncoder().encode(JSON.stringify({ type: 'web', jwk: pubJwk }));
const bundleBytes = wasmIdentity.bundle_bytes();
await fetch(SERVER + '/v1/keys/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -240,49 +209,54 @@ async function registerKey() {
});
}
async function fetchPeerKey(peerFP) {
async function fetchPeerBundle(peerFP) {
const fp = normFP(peerFP);
const cached = derivedKeys[fp];
if (cached) return cached;
if (peerBundles[fp]) return peerBundles[fp];
const resp = await fetch(SERVER + '/v1/keys/' + fp);
if (!resp.ok) throw new Error('Peer not registered');
const data = await resp.json();
const bundleBytes = Uint8Array.from(atob(data.bundle), c => c.charCodeAt(0));
const bundleStr = new TextDecoder().decode(bundleBytes);
let bundle;
try {
bundle = JSON.parse(bundleStr);
} catch(e) {
throw new Error('CLI client — cannot encrypt (different crypto, needs WASM bridge)');
}
if (bundle.type !== 'web') throw new Error('CLI client — cannot encrypt (different crypto)');
const aesKey = await deriveAESKey(bundle.jwk);
derivedKeys[fp] = aesKey;
return aesKey;
const bytes = Uint8Array.from(atob(data.bundle), c => c.charCodeAt(0));
peerBundles[fp] = bytes;
return bytes;
}
async function sendEncrypted(peerFP, plaintext) {
const aesKey = await fetchPeerKey(peerFP);
const encrypted = await aesEncrypt(aesKey, plaintext);
const fp = normFP(peerFP);
const bundleBytes = await fetchPeerBundle(fp);
const envelope = JSON.stringify({
type: 'web',
from: normFP(myFingerprint),
ciphertext: toHex(encrypted)
});
let wireBytes;
if (sessions[fp]) {
// Existing session
const sess = WasmSession.restore(sessions[fp].data);
wireBytes = sess.encrypt(wasmIdentity, plaintext);
sessions[fp].data = sess.save();
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
} else {
// New session via X3DH
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, plaintext);
sessions[fp] = { data: sess.save() };
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
}
await fetch(SERVER + '/v1/messages/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: normFP(peerFP), message: Array.from(new TextEncoder().encode(envelope)) })
body: JSON.stringify({
to: fp,
from: normFP(myFingerprint),
message: Array.from(wireBytes)
})
});
}
async function pollMessages() {
if (!wasmReady) return;
try {
const fp = normFP(myFingerprint);
const resp = await fetch(SERVER + '/v1/messages/poll/' + fp);
@@ -292,36 +266,83 @@ async function pollMessages() {
for (const b64 of msgs) {
try {
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
const str = new TextDecoder().decode(bytes);
// Try JSON (web client message)
// Find existing session for this sender (we don't know sender yet)
// Try decrypt with each session, or let decrypt_wire_message handle it
const existingSessionsJson = {};
for (const [k, v] of Object.entries(sessions)) {
existingSessionsJson[k] = v.data;
}
// Try to decrypt (WASM handles both KeyExchange and Message)
const resultStr = decrypt_wire_message(
mySeedHex,
bytes,
null // existing session found internally
);
const result = JSON.parse(resultStr);
// Save updated session
const senderFP = normFP(result.sender);
sessions[senderFP] = { data: result.session_data };
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
// Resolve alias
let fromLabel = result.sender.slice(0, 19);
try {
const env = JSON.parse(str);
if (env.type === 'web') {
const aesKey = await fetchPeerKey(env.from);
const ct = fromHex(env.ciphertext);
const text = await aesDecrypt(aesKey, ct);
let fromLabel = env.from.slice(0, 12);
// Try to resolve alias
try {
const ar = await fetch(SERVER + '/v1/alias/whois/' + env.from);
const ad = await ar.json();
if (ad.alias) fromLabel = '@' + ad.alias;
} catch(e) {}
const groupTag = env.group ? ' [' + env.group + ']' : '';
addMsg(fromLabel + groupTag, text, false);
continue;
}
} catch(e) { /* not JSON, might be CLI bincode */ }
const ar = await fetch(SERVER + '/v1/alias/whois/' + senderFP);
const ad = await ar.json();
if (ad.alias) fromLabel = '@' + ad.alias;
} catch(e) {}
addSys('[encrypted message from CLI client — use CLI to read]');
addMsg(fromLabel, result.text, false);
} catch(e) {
addSys('[failed to process message]');
// Try with existing session
try {
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
// Brute-force try each session
let decrypted = false;
for (const [senderFP, sessData] of Object.entries(sessions)) {
try {
const resultStr = decrypt_wire_message(mySeedHex, bytes, sessData.data);
const result = JSON.parse(resultStr);
sessions[senderFP] = { data: result.session_data };
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
let fromLabel = result.sender.slice(0, 19);
try {
const ar = await fetch(SERVER + '/v1/alias/whois/' + normFP(result.sender));
const ad = await ar.json();
if (ad.alias) fromLabel = '@' + ad.alias;
} catch(e2) {}
addMsg(fromLabel, result.text, false);
decrypted = true;
break;
} catch(e2) { continue; }
}
if (!decrypted) addSys('[message could not be decrypted]');
} catch(e2) {
addSys('[failed to process message]');
}
}
}
} catch(e) { /* server offline */ }
}
// Load saved sessions
try {
const saved = localStorage.getItem('wz-sessions');
if (saved) {
const parsed = JSON.parse(saved);
for (const [k, v] of Object.entries(parsed)) {
sessions[k] = { data: v };
}
}
} catch(e) {}
// ── UI ──
function ts() {
@@ -355,20 +376,17 @@ function addSys(text) {
// ── Actions ──
async function doGenerate() {
const s = crypto.getRandomValues(new Uint8Array(32));
await initIdentity(s);
generateNewIdentity();
document.getElementById('my-fp').textContent = myFingerprint;
document.getElementById('my-seed').textContent = toHex(seed);
document.getElementById('my-seed').textContent = mySeedHex;
document.getElementById('gen-section').style.display = 'none';
document.getElementById('id-section').style.display = 'block';
}
async function doRecover() {
const input = document.getElementById('recover-input').value.trim();
const input = document.getElementById('recover-input').value.trim().replace(/\s+/g, '');
try {
const s = fromHex(input);
if (s.length !== 32) throw new Error('need 32 bytes');
await initIdentity(s);
initIdentityFromSeed(input);
document.getElementById('my-fp').textContent = myFingerprint;
document.getElementById('my-seed').textContent = '(recovered)';
document.getElementById('gen-section').style.display = 'none';
@@ -453,28 +471,32 @@ async function sendToGroup(groupName, text) {
const members = data.members.filter(m => m !== myFP);
if (members.length === 0) { addSys('No other members in group'); return; }
// Encrypt for each member
// Encrypt for each member using WASM (same crypto as CLI)
const messages = [];
for (const memberFP of members) {
try {
const aesKey = await fetchPeerKey(memberFP);
const encrypted = await aesEncrypt(aesKey, text);
const envelope = JSON.stringify({
type: 'web',
from: myFP,
group: groupName,
ciphertext: toHex(encrypted)
});
messages.push({
to: memberFP,
message: Array.from(new TextEncoder().encode(envelope))
});
const bundleBytes = await fetchPeerBundle(memberFP);
let wireBytes;
if (sessions[memberFP]) {
const sess = WasmSession.restore(sessions[memberFP].data);
wireBytes = sess.encrypt(wasmIdentity, text);
sessions[memberFP].data = sess.save();
} else {
const sess = WasmSession.initiate(wasmIdentity, bundleBytes);
wireBytes = sess.encrypt_key_exchange(wasmIdentity, bundleBytes, text);
sessions[memberFP] = { data: sess.save() };
}
messages.push({ to: memberFP, message: Array.from(wireBytes) });
} catch(e) {
// Silently skip CLI members (different crypto)
// Skip members we can't encrypt for
}
}
// Save sessions
localStorage.setItem('wz-sessions', JSON.stringify(
Object.fromEntries(Object.entries(sessions).map(([k,v]) => [k, v.data]))
));
if (messages.length === 0) { addSys('No compatible web members to send to'); return; }
if (messages.length === 0) { addSys('No members to send to'); return; }
await fetch(SERVER + '/v1/groups/' + groupName + '/send', {
method: 'POST',
@@ -570,10 +592,15 @@ $input.addEventListener('input', function() {
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// Auto-load
// Initialize WASM and auto-load
(async function() {
if (await loadIdentity()) {
enterChat();
try {
await initWasm();
if (loadSavedIdentity()) {
enterChat();
}
} catch(e) {
addSys('Failed to load WASM crypto: ' + e.message);
}
})();
</script>