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:
239
warzone/crates/warzone-wasm/src/lib.rs
Normal file
239
warzone/crates/warzone-wasm/src/lib.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user