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

@@ -0,0 +1,25 @@
[package]
name = "warzone-wasm"
version.workspace = true
edition.workspace = true
[lib]
crate-type = ["cdylib"]
[dependencies]
warzone-protocol = { path = "../warzone-protocol" }
wasm-bindgen = "0.2"
serde = { workspace = true }
serde_json = { workspace = true }
js-sys = "0.3"
web-sys = { version = "0.3", features = ["console"] }
getrandom = { version = "0.2", features = ["js"] }
base64.workspace = true
hex.workspace = true
bincode.workspace = true
x25519-dalek.workspace = true
uuid = { version = "1", features = ["v4", "serde", "js"] }
[profile.release]
opt-level = "s"
lto = true

View File

@@ -0,0 +1,239 @@
//! WASM bridge: exposes warzone-protocol to JavaScript.
//!
//! Gives the web client the EXACT same crypto as the CLI:
//! X25519, ChaCha20-Poly1305, X3DH, Double Ratchet.
use wasm_bindgen::prelude::*;
use warzone_protocol::identity::{IdentityKeyPair, PublicIdentity, Seed};
use warzone_protocol::message::WireMessage;
use warzone_protocol::prekey::{
generate_one_time_pre_keys, generate_signed_pre_key, OneTimePreKeyPublic, PreKeyBundle,
};
use warzone_protocol::ratchet::RatchetState;
use warzone_protocol::x3dh;
use x25519_dalek::PublicKey;
// ── Identity ──
#[wasm_bindgen]
pub struct WasmIdentity {
seed_bytes: [u8; 32],
#[wasm_bindgen(skip)]
pub identity: IdentityKeyPair,
#[wasm_bindgen(skip)]
pub pub_id: PublicIdentity,
}
#[wasm_bindgen]
impl WasmIdentity {
#[wasm_bindgen(constructor)]
pub fn new() -> WasmIdentity {
let seed = Seed::generate();
let seed_bytes = seed.0;
let identity = seed.derive_identity();
let pub_id = identity.public_identity();
WasmIdentity { seed_bytes, identity, pub_id }
}
pub fn from_hex_seed(hex_seed: &str) -> Result<WasmIdentity, JsValue> {
let bytes = hex::decode(hex_seed).map_err(|e| JsValue::from_str(&e.to_string()))?;
if bytes.len() != 32 { return Err(JsValue::from_str("seed must be 32 bytes")); }
let mut seed_bytes = [0u8; 32];
seed_bytes.copy_from_slice(&bytes);
let seed = Seed::from_bytes(seed_bytes);
let identity = seed.derive_identity();
let pub_id = identity.public_identity();
Ok(WasmIdentity { seed_bytes, identity, pub_id })
}
pub fn fingerprint(&self) -> String { self.pub_id.fingerprint.to_string() }
pub fn seed_hex(&self) -> String { hex::encode(self.seed_bytes) }
pub fn fingerprint_hex(&self) -> String { self.pub_id.fingerprint.to_hex() }
pub fn mnemonic(&self) -> String {
Seed::from_bytes(self.seed_bytes).to_mnemonic()
}
/// Generate pre-key bundle as bincode bytes (for server registration).
pub fn bundle_bytes(&self) -> Result<Vec<u8>, JsValue> {
let (_, spk) = generate_signed_pre_key(&self.identity, 1);
let otpks = generate_one_time_pre_keys(0, 10);
let bundle = PreKeyBundle {
identity_key: *self.pub_id.signing.as_bytes(),
identity_encryption_key: *self.pub_id.encryption.as_bytes(),
signed_pre_key: spk,
one_time_pre_key: Some(OneTimePreKeyPublic {
id: otpks[0].id,
public_key: *otpks[0].public.as_bytes(),
}),
};
bincode::serialize(&bundle).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Get the signed pre-key secret as hex (needed for X3DH respond / decrypt).
/// In a real app this would be stored securely, not exposed.
pub fn spk_secret_hex(&self) -> String {
let (secret, _) = generate_signed_pre_key(&self.identity, 1);
hex::encode(secret.to_bytes())
}
}
// ── Session ──
#[wasm_bindgen]
pub struct WasmSession {
ratchet: RatchetState,
}
#[wasm_bindgen]
impl WasmSession {
/// Initiate a new session (Alice side). Returns a WasmSession.
pub fn initiate(
identity: &WasmIdentity,
their_bundle_bytes: &[u8],
) -> Result<WasmSession, JsValue> {
let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes)
.map_err(|e| JsValue::from_str(&format!("bundle: {}", e)))?;
let result = x3dh::initiate(&identity.identity, &bundle)
.map_err(|e| JsValue::from_str(&format!("X3DH: {}", e)))?;
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
Ok(WasmSession {
ratchet: RatchetState::init_alice(result.shared_secret, their_spk),
})
}
/// Encrypt a message. Returns bincode-serialized WireMessage (KeyExchange on first, Message after).
pub fn encrypt_key_exchange(
&mut self,
identity: &WasmIdentity,
their_bundle_bytes: &[u8],
plaintext: &str,
) -> Result<Vec<u8>, JsValue> {
let bundle: PreKeyBundle = bincode::deserialize(their_bundle_bytes)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let result = x3dh::initiate(&identity.identity, &bundle)
.map_err(|e| JsValue::from_str(&format!("X3DH: {}", e)))?;
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
let wire = WireMessage::KeyExchange {
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
sender_identity_encryption_key: *identity.pub_id.encryption.as_bytes(),
ephemeral_public: *result.ephemeral_public.as_bytes(),
used_one_time_pre_key_id: result.used_one_time_pre_key_id,
ratchet_message: encrypted,
};
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Encrypt a message for an existing session.
pub fn encrypt(&mut self, identity: &WasmIdentity, plaintext: &str) -> Result<Vec<u8>, JsValue> {
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
let wire = WireMessage::Message {
sender_fingerprint: identity.pub_id.fingerprint.to_string(),
ratchet_message: encrypted,
};
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
}
/// Serialize session for localStorage persistence.
pub fn save(&self) -> Result<String, JsValue> {
let bytes = bincode::serialize(&self.ratchet).map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bytes))
}
/// Restore session from localStorage.
pub fn restore(data: &str) -> Result<WasmSession, JsValue> {
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let ratchet: RatchetState = bincode::deserialize(&bytes)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(WasmSession { ratchet })
}
}
// ── Decrypt (standalone function, handles both wire message types) ──
/// Decrypt a bincode WireMessage. Returns JSON string:
/// { "sender": "fp", "text": "...", "new_session": bool, "session_data": "base64..." }
#[wasm_bindgen]
pub fn decrypt_wire_message(
identity_hex_seed: &str,
message_bytes: &[u8],
existing_session_b64: Option<String>,
) -> Result<String, JsValue> {
let seed_bytes = hex::decode(identity_hex_seed)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let mut sb = [0u8; 32];
sb.copy_from_slice(&seed_bytes);
let seed = Seed::from_bytes(sb);
let id = seed.derive_identity();
let wire: WireMessage = bincode::deserialize(message_bytes)
.map_err(|e| JsValue::from_str(&format!("deserialize wire: {}", e)))?;
match wire {
WireMessage::KeyExchange {
sender_fingerprint,
sender_identity_encryption_key,
ephemeral_public,
used_one_time_pre_key_id: _,
ratchet_message,
} => {
// For X3DH respond we need the signed pre-key secret.
// Re-derive it deterministically from the seed (same as init).
let (spk_secret, _) = generate_signed_pre_key(&id, 1);
let their_id = PublicKey::from(sender_identity_encryption_key);
let their_eph = PublicKey::from(ephemeral_public);
let shared = x3dh::respond(&id, &spk_secret, None, &their_id, &their_eph)
.map_err(|e| JsValue::from_str(&format!("X3DH respond: {}", e)))?;
let mut ratchet = RatchetState::init_bob(shared, spk_secret);
let plain = ratchet.decrypt(&ratchet_message)
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
let session_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bincode::serialize(&ratchet).unwrap_or_default(),
);
Ok(serde_json::json!({
"sender": sender_fingerprint,
"text": String::from_utf8_lossy(&plain),
"new_session": true,
"session_data": session_b64,
}).to_string())
}
WireMessage::Message {
sender_fingerprint,
ratchet_message,
} => {
let session_data = existing_session_b64
.ok_or_else(|| JsValue::from_str("no session for this peer"))?;
let session_bytes = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD, &session_data,
).map_err(|e| JsValue::from_str(&e.to_string()))?;
let mut ratchet: RatchetState = bincode::deserialize(&session_bytes)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let plain = ratchet.decrypt(&ratchet_message)
.map_err(|e| JsValue::from_str(&format!("decrypt: {}", e)))?;
let session_b64 = base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&bincode::serialize(&ratchet).unwrap_or_default(),
);
Ok(serde_json::json!({
"sender": sender_fingerprint,
"text": String::from_utf8_lossy(&plain),
"new_session": false,
"session_data": session_b64,
}).to_string())
}
}
}