Add WASM self-test, bundle debug, /selftest and /bundleinfo commands

/selftest — runs full Alice→Bob encrypt/decrypt cycle within WASM
  (tests X3DH + Double Ratchet + bincode serialize/deserialize)

/bundleinfo — dumps bundle contents, verifies SPK secret matches
  SPK public key in the registered bundle

These help isolate whether the bug is in WASM crypto (self-test fails)
or in CLI↔WASM interop (self-test passes but cross-client fails).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-27 09:06:08 +04:00
parent c966f3bd64
commit 9814b0d39e
2 changed files with 102 additions and 1 deletions

View File

@@ -144,7 +144,7 @@ const WEB_HTML: &str = r##"<!DOCTYPE html>
</div>
<script type="module">
import init, { WasmIdentity, WasmSession, decrypt_wire_message } from '/wasm/warzone_wasm.js';
import init, { WasmIdentity, WasmSession, decrypt_wire_message, self_test, debug_bundle_info } from '/wasm/warzone_wasm.js';
const SERVER = window.location.origin;
const $messages = document.getElementById('messages');
@@ -581,6 +581,20 @@ async function doSend() {
addSys('SPK secret: ' + (mySpkSecretHex ? mySpkSecretHex.slice(0,16) + '...' : 'NONE'));
return;
}
if (text === '/selftest') {
try {
const r = self_test();
addSys(r);
} catch(e) { addSys('Self-test FAILED: ' + e); }
return;
}
if (text === '/bundleinfo') {
try {
const r = debug_bundle_info(wasmIdentity);
addSys(r);
} catch(e) { addSys('Bundle info error: ' + e); }
return;
}
if (text === '/quit') { window.close(); return; }
if (text === '/glist') { await groupList(); return; }
if (text === '/dm') { currentGroup = null; addSys('Switched to DM mode'); $peerInput.value = localStorage.getItem('wz-peer') || ''; return; }

View File

@@ -200,8 +200,95 @@ impl WasmSession {
}
}
// ── Self-test (verifies full encrypt/decrypt cycle within WASM) ──
#[wasm_bindgen]
pub fn self_test() -> Result<String, JsValue> {
// Alice
let alice_seed = Seed::generate();
let alice_id = alice_seed.derive_identity();
let alice_pub = alice_id.public_identity();
// Bob
let bob_seed = Seed::generate();
let bob_id = bob_seed.derive_identity();
let bob_pub = bob_id.public_identity();
// Bob's pre-key bundle
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
let bob_otpks = generate_one_time_pre_keys(0, 5);
let bob_bundle = PreKeyBundle {
identity_key: *bob_pub.signing.as_bytes(),
identity_encryption_key: *bob_pub.encryption.as_bytes(),
signed_pre_key: bob_spk,
one_time_pre_key: Some(OneTimePreKeyPublic {
id: bob_otpks[0].id,
public_key: *bob_otpks[0].public.as_bytes(),
}),
};
let bob_bundle_bytes = bincode::serialize(&bob_bundle)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
// Alice initiates X3DH and encrypts
let x3dh_result = x3dh::initiate(&alice_id, &bob_bundle)
.map_err(|e| JsValue::from_str(&format!("X3DH initiate: {}", e)))?;
let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key);
let mut alice_ratchet = RatchetState::init_alice(x3dh_result.shared_secret, their_spk);
let encrypted = alice_ratchet.encrypt(b"hello from WASM self-test")
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
let wire = WireMessage::KeyExchange {
sender_fingerprint: alice_pub.fingerprint.to_string(),
sender_identity_encryption_key: *alice_pub.encryption.as_bytes(),
ephemeral_public: *x3dh_result.ephemeral_public.as_bytes(),
used_one_time_pre_key_id: x3dh_result.used_one_time_pre_key_id,
ratchet_message: encrypted,
};
let wire_bytes = bincode::serialize(&wire)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
// Bob decrypts using decrypt_wire_message
let bob_spk_hex = hex::encode(bob_spk_secret.to_bytes());
let bob_seed_hex = hex::encode(bob_seed.0);
let result_str = decrypt_wire_message(&bob_seed_hex, &bob_spk_hex, &wire_bytes, None)?;
let result: serde_json::Value = serde_json::from_str(&result_str)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let text = result.get("text").and_then(|v| v.as_str()).unwrap_or("MISSING");
Ok(format!(
"WASM self-test: alice_fp={}, bob_fp={}, wire_bytes={}, decrypted='{}', PASS={}",
alice_pub.fingerprint, bob_pub.fingerprint, wire_bytes.len(), text,
text == "hello from WASM self-test"
))
}
// ── Decrypt ──
/// Debug: dump what the WASM identity's bundle looks like (for comparing with CLI).
#[wasm_bindgen]
pub fn debug_bundle_info(identity: &mut WasmIdentity) -> Result<String, JsValue> {
let bundle_bytes = identity.bundle_bytes()?;
let bundle: PreKeyBundle = bincode::deserialize(&bundle_bytes)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
let spk_pub_hex = hex::encode(bundle.signed_pre_key.public_key);
let ik_hex = hex::encode(bundle.identity_key);
let iek_hex = hex::encode(bundle.identity_encryption_key);
let spk_secret_hex = identity.spk_secret_hex();
// Verify SPK matches
let spk_secret = x25519_dalek::StaticSecret::from(identity.spk_secret_bytes);
let derived_pub = PublicKey::from(&spk_secret);
let matches = *derived_pub.as_bytes() == bundle.signed_pre_key.public_key;
Ok(format!(
"bundle_size={}, ik={}, iek={}, spk_pub={}, spk_secret={}, spk_matches={}",
bundle_bytes.len(), &ik_hex[..16], &iek_hex[..16], &spk_pub_hex[..16], &spk_secret_hex[..16], matches
))
}
/// 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..." }