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:
1
warzone/Cargo.lock
generated
1
warzone/Cargo.lock
generated
@@ -2646,6 +2646,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bincode",
|
||||
"ed25519-dalek",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
"js-sys",
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user