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) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 08:52:44 +04:00
parent ab296df825
commit 99da095a0f
4 changed files with 106 additions and 35 deletions

1
warzone/Cargo.lock generated
View File

@@ -2646,6 +2646,7 @@ version = "0.1.0"
dependencies = [
"base64",
"bincode",
"ed25519-dalek",
"getrandom 0.2.17",
"hex",
"js-sys",

View File

@@ -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));

View File

@@ -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]

View File

@@ -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<Vec<u8>>,
}
#[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<WasmIdentity, JsValue> {
@@ -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<Vec<u8>, 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<Vec<u8>, 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<PreKeyBundle, String> {
// 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<Vec<u8>, 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<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()))?;
@@ -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<String>,
) -> Result<String, JsValue> {
@@ -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);