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

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