From 99da095a0fca435bf4d76dd2b0f37c246dac716d Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Fri, 27 Mar 2026 08:52:44 +0400 Subject: [PATCH] Fix WASM decrypt: store SPK secret, pass to decrypt_wire_message Root cause: WASM was regenerating random pre-keys on every call to decrypt_wire_message, instead of using the SPK that was registered with the server. CLI sender encrypts to the registered SPK, but WASM was trying to decrypt with a different random key. Fix: - WasmIdentity now stores spk_secret_bytes internally - SPK secret persisted to localStorage as 'wz-spk' - On load: restored from localStorage, not regenerated - bundle_bytes() uses stored SPK secret (cached, deterministic) - decrypt_wire_message() takes spk_secret_hex parameter - Web UI passes stored SPK to all decrypt calls Co-Authored-By: Claude Opus 4.6 (1M context) --- warzone/Cargo.lock | 1 + .../crates/warzone-server/src/routes/web.rs | 24 +++- warzone/crates/warzone-wasm/Cargo.toml | 1 + warzone/crates/warzone-wasm/src/lib.rs | 115 +++++++++++++----- 4 files changed, 106 insertions(+), 35 deletions(-) diff --git a/warzone/Cargo.lock b/warzone/Cargo.lock index 1fcda2e..1554bd3 100644 --- a/warzone/Cargo.lock +++ b/warzone/Cargo.lock @@ -2646,6 +2646,7 @@ version = "0.1.0" dependencies = [ "base64", "bincode", + "ed25519-dalek", "getrandom 0.2.17", "hex", "js-sys", diff --git a/warzone/crates/warzone-server/src/routes/web.rs b/warzone/crates/warzone-server/src/routes/web.rs index b0fb649..6fecd8c 100644 --- a/warzone/crates/warzone-server/src/routes/web.rs +++ b/warzone/crates/warzone-server/src/routes/web.rs @@ -177,10 +177,25 @@ async function initWasm() { wasmReady = true; } +let mySpkSecretHex = ''; + function initIdentityFromSeed(hexSeed) { wasmIdentity = WasmIdentity.from_hex_seed(hexSeed); myFingerprint = wasmIdentity.fingerprint(); mySeedHex = wasmIdentity.seed_hex(); + + // Restore or generate SPK secret + const savedSpk = localStorage.getItem('wz-spk'); + if (savedSpk) { + wasmIdentity.set_spk_secret_hex(savedSpk); + mySpkSecretHex = savedSpk; + dbg('Restored SPK secret from localStorage'); + } else { + mySpkSecretHex = wasmIdentity.spk_secret_hex(); + localStorage.setItem('wz-spk', mySpkSecretHex); + dbg('Generated new SPK secret'); + } + localStorage.setItem('wz-seed', mySeedHex); localStorage.setItem('wz-fp', myFingerprint); } @@ -189,8 +204,11 @@ function generateNewIdentity() { wasmIdentity = new WasmIdentity(); myFingerprint = wasmIdentity.fingerprint(); mySeedHex = wasmIdentity.seed_hex(); + mySpkSecretHex = wasmIdentity.spk_secret_hex(); localStorage.setItem('wz-seed', mySeedHex); localStorage.setItem('wz-fp', myFingerprint); + localStorage.setItem('wz-spk', mySpkSecretHex); + dbg('New identity, SPK secret stored'); } function loadSavedIdentity() { @@ -198,7 +216,7 @@ function loadSavedIdentity() { if (!saved) { dbg('No saved seed'); return false; } try { initIdentityFromSeed(saved); - dbg('Loaded identity:', myFingerprint); + dbg('Loaded identity:', myFingerprint, 'has SPK:', !!mySpkSecretHex); return true; } catch(e) { dbg('Failed to load identity:', e); @@ -295,7 +313,7 @@ async function pollMessages() { let decrypted = false; try { dbg('Trying decrypt as KeyExchange (no session)...'); - const resultStr = decrypt_wire_message(mySeedHex, bytes, null); + const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, null); const result = JSON.parse(resultStr); dbg('Decrypted!', result.new_session ? 'new session' : 'existing', 'from:', result.sender); @@ -323,7 +341,7 @@ async function pollMessages() { for (const [senderFP, sessData] of Object.entries(sessions)) { try { dbg('Trying session for', senderFP); - const resultStr = decrypt_wire_message(mySeedHex, bytes, sessData.data); + const resultStr = decrypt_wire_message(mySeedHex, mySpkSecretHex, bytes, sessData.data); const result = JSON.parse(resultStr); dbg('Decrypted with session', senderFP, ':', result.text.slice(0, 30)); diff --git a/warzone/crates/warzone-wasm/Cargo.toml b/warzone/crates/warzone-wasm/Cargo.toml index 39a4b89..14ce1f0 100644 --- a/warzone/crates/warzone-wasm/Cargo.toml +++ b/warzone/crates/warzone-wasm/Cargo.toml @@ -18,6 +18,7 @@ base64.workspace = true hex.workspace = true bincode.workspace = true x25519-dalek.workspace = true +ed25519-dalek.workspace = true uuid = { version = "1", features = ["v4", "serde", "js"] } [profile.release] diff --git a/warzone/crates/warzone-wasm/src/lib.rs b/warzone/crates/warzone-wasm/src/lib.rs index c823047..87ecb06 100644 --- a/warzone/crates/warzone-wasm/src/lib.rs +++ b/warzone/crates/warzone-wasm/src/lib.rs @@ -23,6 +23,9 @@ pub struct WasmIdentity { pub identity: IdentityKeyPair, #[wasm_bindgen(skip)] pub pub_id: PublicIdentity, + // Pre-key secrets (generated once, reused for decrypt) + spk_secret_bytes: [u8; 32], + bundle_cache: Option>, } #[wasm_bindgen] @@ -30,10 +33,7 @@ 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 } + Self::from_seed(seed) } pub fn from_hex_seed(hex_seed: &str) -> Result { @@ -41,10 +41,7 @@ impl WasmIdentity { 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 }) + Ok(Self::from_seed(Seed::from_bytes(seed_bytes))) } pub fn fingerprint(&self) -> String { self.pub_id.fingerprint.to_string() } @@ -55,11 +52,72 @@ impl WasmIdentity { Seed::from_bytes(self.seed_bytes).to_mnemonic() } - /// Generate pre-key bundle as bincode bytes (for server registration). - pub fn bundle_bytes(&self) -> Result, JsValue> { - let (_, spk) = generate_signed_pre_key(&self.identity, 1); + /// Get the pre-key bundle as bincode bytes (for server registration). + /// The bundle is generated once and cached. The SPK secret is stored internally. + pub fn bundle_bytes(&mut self) -> Result, JsValue> { + if let Some(ref cached) = self.bundle_cache { + return Ok(cached.clone()); + } + let bundle = self.generate_bundle_internal() + .map_err(|e| JsValue::from_str(&e.to_string()))?; + let bytes = bincode::serialize(&bundle) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + self.bundle_cache = Some(bytes.clone()); + Ok(bytes) + } + + /// Get the SPK secret as hex (for persistence in localStorage). + pub fn spk_secret_hex(&self) -> String { + hex::encode(self.spk_secret_bytes) + } + + /// Restore the SPK secret from hex (loaded from localStorage). + pub fn set_spk_secret_hex(&mut self, hex: &str) -> Result<(), JsValue> { + let bytes = hex::decode(hex).map_err(|e| JsValue::from_str(&e.to_string()))?; + if bytes.len() != 32 { return Err(JsValue::from_str("SPK secret must be 32 bytes")); } + self.spk_secret_bytes.copy_from_slice(&bytes); + Ok(()) + } +} + +impl WasmIdentity { + fn from_seed(seed: Seed) -> Self { + let seed_bytes = seed.0; + let identity = seed.derive_identity(); + let pub_id = identity.public_identity(); + + // Generate pre-keys ONCE + let (spk_secret, _) = generate_signed_pre_key(&identity, 1); + let spk_secret_bytes = spk_secret.to_bytes(); + + WasmIdentity { + seed_bytes, + identity, + pub_id, + spk_secret_bytes, + bundle_cache: None, + } + } + + fn generate_bundle_internal(&self) -> Result { + // Recreate SPK from stored secret + let spk_secret = x25519_dalek::StaticSecret::from(self.spk_secret_bytes); + let spk_public = PublicKey::from(&spk_secret); + + // Sign the SPK public key + use ed25519_dalek::Signer; + let signature = self.identity.signing.sign(spk_public.as_bytes()); + + let spk = warzone_protocol::prekey::SignedPreKey { + id: 1, + public_key: *spk_public.as_bytes(), + signature: signature.to_bytes().to_vec(), + timestamp: js_sys::Date::now() as i64 / 1000, + }; + let otpks = generate_one_time_pre_keys(0, 10); - let bundle = PreKeyBundle { + + Ok(PreKeyBundle { identity_key: *self.pub_id.signing.as_bytes(), identity_encryption_key: *self.pub_id.encryption.as_bytes(), signed_pre_key: spk, @@ -67,15 +125,7 @@ impl WasmIdentity { 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()) + }) } } @@ -88,7 +138,6 @@ pub struct WasmSession { #[wasm_bindgen] impl WasmSession { - /// Initiate a new session (Alice side). Returns a WasmSession. pub fn initiate( identity: &WasmIdentity, their_bundle_bytes: &[u8], @@ -103,7 +152,6 @@ impl WasmSession { }) } - /// Encrypt a message. Returns bincode-serialized WireMessage (KeyExchange on first, Message after). pub fn encrypt_key_exchange( &mut self, identity: &WasmIdentity, @@ -128,7 +176,6 @@ impl WasmSession { 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, JsValue> { let encrypted = self.ratchet.encrypt(plaintext.as_bytes()) .map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?; @@ -139,13 +186,11 @@ impl WasmSession { bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string())) } - /// Serialize session for localStorage persistence. pub fn save(&self) -> Result { 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 { let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, data) .map_err(|e| JsValue::from_str(&e.to_string()))?; @@ -155,13 +200,15 @@ impl WasmSession { } } -// ── Decrypt (standalone function, handles both wire message types) ── +// ── Decrypt ── -/// Decrypt a bincode WireMessage. Returns JSON string: -/// { "sender": "fp", "text": "...", "new_session": bool, "session_data": "base64..." } +/// Decrypt a bincode WireMessage. `spk_secret_hex` is the signed pre-key secret +/// (stored in localStorage, generated during identity creation). +/// Returns JSON: { "sender": "fp", "text": "...", "new_session": bool, "session_data": "base64..." } #[wasm_bindgen] pub fn decrypt_wire_message( identity_hex_seed: &str, + spk_secret_hex: &str, message_bytes: &[u8], existing_session_b64: Option, ) -> Result { @@ -183,9 +230,13 @@ pub fn decrypt_wire_message( 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); + // Use the STORED SPK secret, not a regenerated one + let spk_bytes = hex::decode(spk_secret_hex) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + let mut spk_arr = [0u8; 32]; + spk_arr.copy_from_slice(&spk_bytes); + let spk_secret = x25519_dalek::StaticSecret::from(spk_arr); + let their_id = PublicKey::from(sender_identity_encryption_key); let their_eph = PublicKey::from(ephemeral_public);