fix: WASM double-X3DH bug, federated aliases, deploy tooling

WASM fix (critical):
- encrypt_key_exchange_with_id was calling x3dh::initiate a second time,
  generating a new ephemeral key that didn't match the ratchet — receiver
  always failed to decrypt. Now stores X3DH result from initiate() and
  reuses it. Added 2 protocol tests confirming the fix + the bug.
- Bumped service worker cache to wz-v2 to force browsers to re-fetch.
- Disabled wasm-opt for Hetzner builds (libc compat issue).

Federation — alias support:
- resolve_alias falls back to federation peer if not found locally
- register_alias checks peer server before allowing — globally unique aliases
- Added resolve_remote_alias() and is_alias_taken_remote() to FederationHandle

Federation — key proxy fix:
- Remote bundles no longer cached locally (stale cache caused decrypt failures)
- Local vs remote determined by device: prefix in keys DB

Client fixes:
- Self-messaging blocked ("Cannot send messages to yourself")
- /peer <self> blocked
- last_dm_peer never set to self
- /r <message> sends reply inline (switches peer + sends in one command)

Deploy tooling:
- scripts/build-linux.sh with --ship (build + deploy + destroy)
- --update-all, --status, --logs commands
- WASM rebuilt on Hetzner VM before server binary
- deploy/ directory: systemd service, federation configs, setup script
- Journald log cap (50MB, 7-day retention)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-28 22:59:19 +04:00
parent f8eaf30bb4
commit dbf5d136cf
16 changed files with 1026 additions and 24 deletions

View File

@@ -19,7 +19,7 @@ impl App {
db: &LocalDb,
client: &ServerClient,
) {
let text = self.input.trim().to_string();
let mut text = self.input.trim().to_string();
self.input.clear();
self.cursor_pos = 0;
@@ -223,15 +223,27 @@ impl App {
}
return;
}
if text == "/r" || text == "/reply" {
if text == "/r" || text == "/reply" || text.starts_with("/r ") || text.starts_with("/reply ") {
let last = self.last_dm_peer.lock().unwrap().clone();
if let Some(ref peer) = last {
self.peer_fp = Some(peer.clone());
self.add_message(ChatLine { sender: "system".into(), text: format!("→ switched to {}", &peer[..peer.len().min(16)]), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
// If there's a message after /r, mutate text and fall through to send
let reply_msg = if text.starts_with("/reply ") {
text[7..].trim().to_string()
} else if text.starts_with("/r ") {
text[3..].trim().to_string()
} else {
String::new()
};
if reply_msg.is_empty() {
return; // Just switch peer
}
text = reply_msg; // Fall through to send logic below
} else {
self.add_message(ChatLine { sender: "system".into(), text: "No one to reply to".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
return;
}
return;
}
if text.starts_with("/peer ") || text.starts_with("/p ") {
let text = if text.starts_with("/p ") { format!("/peer {}", &text[3..]) } else { text.clone() };
@@ -244,6 +256,10 @@ impl App {
} else {
raw
};
if normfp(&fp) == normfp(&self.our_fp) {
self.add_message(ChatLine { sender: "system".into(), text: "Cannot set yourself as peer".into(), is_system: true, is_self: false, message_id: None, timestamp: Local::now() });
return;
}
self.add_message(ChatLine {
sender: "system".into(),
text: format!("Peer set to {}", fp),
@@ -355,6 +371,18 @@ impl App {
}
};
// Prevent self-messaging (causes ratchet corruption)
if normfp(&peer) == normfp(&self.our_fp) {
self.add_message(ChatLine {
sender: "system".into(),
text: "Cannot send messages to yourself".into(),
is_system: true,
is_self: false,
message_id: None, timestamp: Local::now(),
});
return;
}
let peer_fp = match Fingerprint::from_hex(&peer) {
Ok(fp) => fp,
Err(_) => {

View File

@@ -112,7 +112,9 @@ fn process_wire_message(
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
if normfp(&sender_fingerprint) != normfp(our_fp) {
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
}
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),
@@ -159,7 +161,9 @@ fn process_wire_message(
Ok(plaintext) => {
let text = String::from_utf8_lossy(&plaintext).to_string();
let _ = db.save_session(&sender_fp, &state);
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
if normfp(&sender_fingerprint) != normfp(our_fp) {
*last_dm_peer.lock().unwrap() = Some(sender_fingerprint.clone());
}
store_received(db, &sender_fingerprint, &text);
messages.lock().unwrap().push(ChatLine {
sender: sender_fingerprint[..sender_fingerprint.len().min(12)].to_string(),

View File

@@ -163,4 +163,141 @@ mod tests {
assert_eq!(alice_result.shared_secret, bob_secret);
}
/// Simulate the EXACT web client (WASM) flow:
/// 1. Alice: generate identity + SPK, create bundle, register
/// 2. Bob: same
/// 3. Alice: fetch Bob's bundle, WasmSession::initiate (X3DH), encrypt_key_exchange
/// 4. Bob: receive wire bytes, decrypt_wire_message (X3DH respond + ratchet decrypt)
#[test]
fn web_client_x3dh_roundtrip() {
use crate::identity::Seed;
use crate::message::WireMessage;
use crate::ratchet::RatchetState;
// === Alice ===
let alice_seed = Seed::generate();
let alice_id = alice_seed.derive_identity();
let alice_pub = alice_id.public_identity();
let (alice_spk_secret, alice_spk) = generate_signed_pre_key(&alice_id, 1);
let alice_bundle = PreKeyBundle {
identity_key: *alice_pub.signing.as_bytes(),
identity_encryption_key: *alice_pub.encryption.as_bytes(),
signed_pre_key: alice_spk,
one_time_pre_key: None, // web client: no OTPKs
};
// === Bob ===
let bob_seed = Seed::generate();
let bob_id = bob_seed.derive_identity();
let bob_pub = bob_id.public_identity();
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
let bob_spk_secret_bytes = bob_spk_secret.to_bytes();
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: None,
};
let bob_bundle_bytes = bincode::serialize(&bob_bundle).unwrap();
// === Alice sends to Bob (simulating WasmSession::initiate + encrypt_key_exchange_with_id) ===
// Step 1: WasmSession::initiate — X3DH + init ratchet
let x3dh_result = initiate(&alice_id, &bob_bundle).unwrap();
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);
// Step 2: encrypt_key_exchange_with_id — use SAME x3dh_result (NOT re-initiate!)
let encrypted = alice_ratchet.encrypt(b"hello bob").unwrap();
let wire = WireMessage::KeyExchange {
id: "test-msg-001".to_string(),
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).unwrap();
// === Bob decrypts (simulating decrypt_wire_message) ===
let wire_in: WireMessage = bincode::deserialize(&wire_bytes).unwrap();
match wire_in {
WireMessage::KeyExchange {
sender_identity_encryption_key,
ephemeral_public,
ratchet_message,
..
} => {
let bob_spk_secret_restored = StaticSecret::from(bob_spk_secret_bytes);
let their_id = PublicKey::from(sender_identity_encryption_key);
let their_eph = PublicKey::from(ephemeral_public);
let shared = respond(
&bob_id, &bob_spk_secret_restored, None, &their_id, &their_eph,
).unwrap();
let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes);
let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet);
let plaintext = bob_ratchet.decrypt(&ratchet_message).unwrap();
assert_eq!(plaintext, b"hello bob");
}
_ => panic!("expected KeyExchange"),
}
}
/// Test that the OLD buggy flow (double X3DH initiate) fails,
/// confirming the bug we found.
#[test]
fn double_x3dh_initiate_fails() {
use crate::identity::Seed;
use crate::ratchet::RatchetState;
let alice_seed = Seed::generate();
let alice_id = alice_seed.derive_identity();
let alice_pub = alice_id.public_identity();
let bob_seed = Seed::generate();
let bob_id = bob_seed.derive_identity();
let bob_pub = bob_id.public_identity();
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
let bob_spk_secret_bytes = bob_spk_secret.to_bytes();
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: None,
};
// FIRST X3DH — used for ratchet
let result1 = initiate(&alice_id, &bob_bundle).unwrap();
let their_spk = PublicKey::from(bob_bundle.signed_pre_key.public_key);
let mut alice_ratchet = RatchetState::init_alice(result1.shared_secret, their_spk);
let encrypted = alice_ratchet.encrypt(b"test").unwrap();
// SECOND X3DH — different ephemeral key (THE BUG)
let result2 = initiate(&alice_id, &bob_bundle).unwrap();
// result2.ephemeral_public != result1.ephemeral_public
assert_ne!(
result1.ephemeral_public.as_bytes(),
result2.ephemeral_public.as_bytes(),
"two X3DH initiates should produce different ephemeral keys"
);
// Bob tries to decrypt using result2's ephemeral (wrong one)
let bob_spk_restored = StaticSecret::from(bob_spk_secret_bytes);
let shared = respond(
&bob_id, &bob_spk_restored, None,
&alice_pub.encryption, &result2.ephemeral_public,
).unwrap();
// The shared secrets DIFFER because different ephemeral keys
assert_ne!(result1.shared_secret, shared, "mismatched ephemeral should produce different shared secret");
// Decryption should FAIL
let bob_spk_for_ratchet = StaticSecret::from(bob_spk_secret_bytes);
let mut bob_ratchet = RatchetState::init_bob(shared, bob_spk_for_ratchet);
assert!(bob_ratchet.decrypt(&encrypted).is_err(), "decrypt should fail with wrong shared secret");
}
}

View File

@@ -65,6 +65,8 @@ pub struct FederationHandle {
pub remote_presence: Arc<Mutex<RemotePresence>>,
/// Channel to send messages over the outgoing WS to the peer.
pub outgoing: FederationSender,
/// HTTP client for one-shot requests (key fetch, etc.)
pub client: reqwest::Client,
}
impl FederationHandle {
@@ -72,10 +74,15 @@ impl FederationHandle {
let remote_presence = Arc::new(Mutex::new(RemotePresence::new(
config.peer.id.clone(),
)));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
.expect("failed to build HTTP client");
FederationHandle {
config,
remote_presence,
outgoing: Arc::new(Mutex::new(None)),
client,
}
}
@@ -96,6 +103,41 @@ impl FederationHandle {
self.send_json(msg).await
}
/// Fetch a pre-key bundle from the peer server (HTTP GET fallback).
/// Used when a local key lookup fails and the fingerprint is on the remote.
pub async fn fetch_remote_bundle(&self, fingerprint: &str) -> Option<Vec<u8>> {
let url = format!("{}/v1/keys/{}", self.config.peer.url, fingerprint);
let resp = self.client.get(&url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let data: serde_json::Value = resp.json().await.ok()?;
let bundle_b64 = data.get("bundle")?.as_str()?;
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, bundle_b64).ok()
}
/// Resolve an alias on the peer server.
/// Returns Some(fingerprint) if the peer knows this alias.
pub async fn resolve_remote_alias(&self, alias: &str) -> Option<String> {
let url = format!("{}/v1/alias/resolve/{}", self.config.peer.url, alias);
let resp = self.client.get(&url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let data: serde_json::Value = resp.json().await.ok()?;
// Check for error (alias not found on peer)
if data.get("error").is_some() {
return None;
}
data.get("fingerprint").and_then(|v| v.as_str()).map(String::from)
}
/// Check if an alias is already taken on the peer server.
/// Returns true if the alias exists on the peer (taken).
pub async fn is_alias_taken_remote(&self, alias: &str) -> bool {
self.resolve_remote_alias(alias).await.is_some()
}
/// Push local presence to peer via the persistent WS.
pub async fn push_presence(&self, fingerprints: Vec<String>) -> bool {
let msg = serde_json::json!({
@@ -171,10 +213,42 @@ pub async fn outgoing_ws_loop(
};
let _ = handle.push_presence(fps).await;
// Spawn task to forward outgoing channel to WS
// Spawn task to forward outgoing channel + periodic ping to WS
let send_task = tokio::spawn(async move {
while let Some(msg) = out_rx.recv().await {
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(msg)).await.is_err() {
let mut ping_interval = tokio::time::interval(std::time::Duration::from_secs(15));
loop {
tokio::select! {
msg = out_rx.recv() => {
match msg {
Some(text) => {
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Text(text)).await.is_err() {
break;
}
}
None => break,
}
}
_ = ping_interval.tick() => {
if ws_tx.send(tokio_tungstenite::tungstenite::Message::Ping(vec![])).await.is_err() {
break;
}
}
}
}
});
// Spawn task to periodically re-push presence
let presence_handle = handle.clone();
let presence_conns = state.connections.clone();
let presence_task = tokio::spawn(async move {
let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
loop {
interval.tick().await;
let fps: Vec<String> = {
let conns = presence_conns.lock().await;
conns.keys().cloned().collect()
};
if !presence_handle.push_presence(fps).await {
break;
}
}
@@ -182,13 +256,19 @@ pub async fn outgoing_ws_loop(
// Read incoming messages from peer
while let Some(Ok(msg)) = ws_rx.next().await {
if let tokio_tungstenite::tungstenite::Message::Text(text) = msg {
handle_incoming_federation_msg(&text, &handle, &state).await;
match msg {
tokio_tungstenite::tungstenite::Message::Text(text) => {
handle_incoming_federation_msg(&text, &handle, &state).await;
}
tokio_tungstenite::tungstenite::Message::Pong(_) => {} // keepalive response
tokio_tungstenite::tungstenite::Message::Close(_) => break,
_ => {}
}
}
// Connection lost
send_task.abort();
presence_task.abort();
{
let mut guard = handle.outgoing.lock().await;
*guard = None;

View File

@@ -152,6 +152,13 @@ async fn register_alias(
delete_alias_record(&state.db.aliases, &existing)?;
}
// Check if alias is taken on federation peer (globally unique)
if let Some(ref federation) = state.federation {
if federation.is_alias_taken_remote(&alias).await {
return Ok(Json(serde_json::json!({ "error": "alias already taken on federated server" })));
}
}
// Remove old alias for this fingerprint (one alias per person)
if let Some(old_alias_bytes) = state.db.aliases.get(format!("fp:{}", fp).as_bytes())? {
let old_alias = String::from_utf8_lossy(&old_alias_bytes).to_string();
@@ -292,7 +299,20 @@ async fn resolve_alias(
})))
}
}
None => Ok(Json(serde_json::json!({ "error": "alias not found" }))),
None => {
// Try federation peer
if let Some(ref federation) = state.federation {
if let Some(fp) = federation.resolve_remote_alias(&alias).await {
tracing::info!("Alias @{} resolved via federation: {}", alias, fp);
return Ok(Json(serde_json::json!({
"alias": alias,
"fingerprint": fp,
"federated": true,
})));
}
}
Ok(Json(serde_json::json!({ "error": "alias not found" })))
}
}
}

View File

@@ -54,7 +54,7 @@ struct RegisterResponse {
}
async fn register_keys(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<RegisterRequest>,
) -> Json<RegisterResponse> {
@@ -85,9 +85,26 @@ async fn get_bundle(
.collect();
tracing::info!("get_bundle: DB contains {} keys: {:?}", all_keys.len(), all_keys);
// Check if this fingerprint registered locally (has a device: entry)
let device_prefix = format!("device:{}:", key);
let is_local = state.db.keys.scan_prefix(device_prefix.as_bytes()).next().is_some();
// For remote clients, always proxy from the federation peer (bundles may change)
if !is_local {
if let Some(ref federation) = state.federation {
if let Some(bundle_bytes) = federation.fetch_remote_bundle(&key).await {
tracing::info!("get_bundle: PROXIED from federation peer for {}", key);
return Ok(Json(serde_json::json!({
"fingerprint": fingerprint,
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &bundle_bytes),
})));
}
}
}
match state.db.keys.get(key.as_bytes()) {
Ok(Some(data)) => {
tracing::info!("get_bundle: FOUND {} bytes for {}", data.len(), key);
tracing::info!("get_bundle: FOUND {} bytes for {} (local={})", data.len(), key, is_local);
Ok(Json(serde_json::json!({
"fingerprint": fingerprint,
"bundle": base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &data),
@@ -130,7 +147,7 @@ struct OtpkEntry {
/// Upload additional one-time pre-keys.
async fn replenish_otpks(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<ReplenishRequest>,
) -> Json<serde_json::Value> {

View File

@@ -71,7 +71,7 @@ fn normalize_fp(fp: &str) -> String {
}
async fn send_message(
_auth: crate::auth_middleware::AuthFingerprint,
State(state): State<AppState>,
Json(req): Json<SendRequest>,
) -> AppResult<Json<serde_json::Value>> {

View File

@@ -50,7 +50,7 @@ async fn pwa_manifest() -> impl IntoResponse {
async fn service_worker() -> impl IntoResponse {
([(header::CONTENT_TYPE, "application/javascript")], r##"
const CACHE = 'wz-v1';
const CACHE = 'wz-v2';
const SHELL = ['/', '/wasm/warzone_wasm.js', '/wasm/warzone_wasm_bg.wasm', '/icon.svg', '/manifest.json'];
self.addEventListener('install', e => {

View File

@@ -3,6 +3,9 @@ name = "warzone-wasm"
version.workspace = true
edition.workspace = true
[package.metadata.wasm-pack.profile.release]
wasm-opt = false
[lib]
crate-type = ["cdylib"]

View File

@@ -132,6 +132,9 @@ impl WasmIdentity {
#[wasm_bindgen]
pub struct WasmSession {
ratchet: RatchetState,
/// Stored X3DH result from initiate() — needed for encrypt_key_exchange
x3dh_ephemeral_public: Option<[u8; 32]>,
x3dh_used_otpk_id: Option<u32>,
}
#[wasm_bindgen]
@@ -147,6 +150,8 @@ impl WasmSession {
let their_spk = PublicKey::from(bundle.signed_pre_key.public_key);
Ok(WasmSession {
ratchet: RatchetState::init_alice(result.shared_secret, their_spk),
x3dh_ephemeral_public: Some(*result.ephemeral_public.as_bytes()),
x3dh_used_otpk_id: result.used_one_time_pre_key_id,
})
}
@@ -162,14 +167,14 @@ impl WasmSession {
pub fn encrypt_key_exchange_with_id(
&mut self,
identity: &WasmIdentity,
their_bundle_bytes: &[u8],
_their_bundle_bytes: &[u8],
plaintext: &str,
msg_id: &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)))?;
// Use the stored X3DH result from initiate() — DO NOT re-initiate
// (re-initiating generates a new ephemeral key that doesn't match the ratchet)
let ephemeral_public = self.x3dh_ephemeral_public
.ok_or_else(|| JsValue::from_str("no X3DH result — call initiate() first"))?;
let encrypted = self.ratchet.encrypt(plaintext.as_bytes())
.map_err(|e| JsValue::from_str(&format!("encrypt: {}", e)))?;
@@ -178,8 +183,8 @@ impl WasmSession {
id: msg_id.to_string(),
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,
ephemeral_public,
used_one_time_pre_key_id: self.x3dh_used_otpk_id,
ratchet_message: encrypted,
};
bincode::serialize(&wire).map_err(|e| JsValue::from_str(&e.to_string()))
@@ -210,7 +215,7 @@ impl WasmSession {
.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 })
Ok(WasmSession { ratchet, x3dh_ephemeral_public: None, x3dh_used_otpk_id: None })
}
}
@@ -615,3 +620,126 @@ pub fn create_sender_key_from_distribution(
let encoded = bincode::serialize(&sender_key).unwrap_or_default();
Ok(hex::encode(encoded))
}
// Tests live in warzone-protocol to avoid js-sys dependency issues.
// See warzone-protocol/src/x3dh.rs tests for web-client simulation.
#[cfg(test)]
#[cfg(target_arch = "wasm32")]
mod tests {
use super::*;
#[test]
fn web_client_to_web_client() {
// === Alice (sender) ===
let mut alice = WasmIdentity::new();
let alice_seed = alice.seed_hex();
let alice_spk = alice.spk_secret_hex();
let alice_bundle = alice.bundle_bytes().unwrap();
// === Bob (receiver) ===
let mut bob = WasmIdentity::new();
let bob_seed = bob.seed_hex();
let bob_spk = bob.spk_secret_hex();
let bob_bundle = bob.bundle_bytes().unwrap();
println!("Alice fp: {}", alice.fingerprint());
println!("Bob fp: {}", bob.fingerprint());
println!("Alice SPK secret: {}...", &alice_spk[..16]);
println!("Bob SPK secret: {}...", &bob_spk[..16]);
// === Alice sends to Bob (exactly like the web JS) ===
// 1. Alice creates session from Bob's bundle
let mut alice_session = WasmSession::initiate(&alice, &bob_bundle).unwrap();
// 2. Alice encrypts with key exchange
let wire_bytes = alice_session
.encrypt_key_exchange_with_id(&alice, &bob_bundle, "hello bob", "msg-001")
.unwrap();
println!("Wire message size: {} bytes", wire_bytes.len());
// === Bob receives and decrypts (exactly like handleIncomingMessage) ===
// First try: decrypt_wire_message with null session (handles KeyExchange)
let result = decrypt_wire_message(&bob_seed, &bob_spk, &wire_bytes, None);
match result {
Ok(json_str) => {
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
println!("Decrypt SUCCESS: {}", json_str);
assert_eq!(parsed["text"].as_str().unwrap(), "hello bob");
assert!(parsed["new_session"].as_bool().unwrap());
println!("Session data present: {}", parsed["session_data"].as_str().is_some());
}
Err(e) => {
panic!("Decrypt FAILED: {:?}", e);
}
}
}
/// Test that restored session (from base64) can decrypt subsequent messages.
#[test]
fn web_client_session_continuity() {
let mut alice = WasmIdentity::new();
let mut bob = WasmIdentity::new();
let bob_seed = bob.seed_hex();
let bob_spk = bob.spk_secret_hex();
let bob_bundle = bob.bundle_bytes().unwrap();
// Alice sends first message (KeyExchange)
let mut alice_session = WasmSession::initiate(&alice, &bob_bundle).unwrap();
let wire1 = alice_session
.encrypt_key_exchange_with_id(&alice, &bob_bundle, "msg one", "id-1")
.unwrap();
// Bob decrypts first message
let result1 = decrypt_wire_message(&bob_seed, &bob_spk, &wire1, None).unwrap();
let parsed1: serde_json::Value = serde_json::from_str(&result1).unwrap();
assert_eq!(parsed1["text"].as_str().unwrap(), "msg one");
let bob_session_data = parsed1["session_data"].as_str().unwrap().to_string();
// Alice sends second message (regular Message, not KeyExchange)
let alice_session_data = alice_session.save().unwrap();
let mut alice_session2 = WasmSession::restore(&alice_session_data).unwrap();
let wire2 = alice_session2
.encrypt_with_id(&alice, "msg two", "id-2")
.unwrap();
// Bob decrypts second message using saved session
let result2 = decrypt_wire_message(&bob_seed, &bob_spk, &wire2, Some(bob_session_data)).unwrap();
let parsed2: serde_json::Value = serde_json::from_str(&result2).unwrap();
assert_eq!(parsed2["text"].as_str().unwrap(), "msg two");
}
/// Test bidirectional: Alice sends to Bob, Bob sends to Alice.
#[test]
fn web_client_bidirectional() {
let mut alice = WasmIdentity::new();
let alice_seed = alice.seed_hex();
let alice_spk = alice.spk_secret_hex();
let alice_bundle = alice.bundle_bytes().unwrap();
let mut bob = WasmIdentity::new();
let bob_seed = bob.seed_hex();
let bob_spk = bob.spk_secret_hex();
let bob_bundle = bob.bundle_bytes().unwrap();
// Alice → Bob
let mut a_session = WasmSession::initiate(&alice, &bob_bundle).unwrap();
let wire_a2b = a_session
.encrypt_key_exchange_with_id(&alice, &bob_bundle, "hi bob", "a1")
.unwrap();
let r1 = decrypt_wire_message(&bob_seed, &bob_spk, &wire_a2b, None).unwrap();
let p1: serde_json::Value = serde_json::from_str(&r1).unwrap();
assert_eq!(p1["text"].as_str().unwrap(), "hi bob");
// Bob → Alice
let mut b_session = WasmSession::initiate(&bob, &alice_bundle).unwrap();
let wire_b2a = b_session
.encrypt_key_exchange_with_id(&bob, &alice_bundle, "hi alice", "b1")
.unwrap();
let r2 = decrypt_wire_message(&alice_seed, &alice_spk, &wire_b2a, None).unwrap();
let p2: serde_json::Value = serde_json::from_str(&r2).unwrap();
assert_eq!(p2["text"].as_str().unwrap(), "hi alice");
}
}