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:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user