X3DH fix: - Added identity_encryption_key (X25519) to PreKeyBundle - initiate() and respond() now use correct DH operations per Signal spec: DH1=IK_a*SPK_b, DH2=EK_a*IK_b, DH3=EK_a*SPK_b, DH4=EK_a*OPK_b - All 17 tests pass including x3dh_shared_secret_matches Web client (served at /): - Identity generation with seed (stored in localStorage) - Recovery from hex-encoded seed - Auto-load saved identity on page load - Fingerprint display (same format as CLI: xxxx:xxxx:xxxx:xxxx) - Key registration with server via /v1/keys/register - Chat UI with message polling (5s interval) - Commands: /help, /info, /seed - Dark theme matching warzone aesthetic Both clients (CLI + Web) now exist: - CLI: warzone init, warzone info, warzone recover - Web: http://localhost:7700/ (served by warzone-server) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
167 lines
5.6 KiB
Rust
167 lines
5.6 KiB
Rust
//! X3DH (Extended Triple Diffie-Hellman) key agreement.
|
|
//! Follows Signal's X3DH specification.
|
|
|
|
use x25519_dalek::{PublicKey, StaticSecret};
|
|
use zeroize::Zeroize;
|
|
|
|
use crate::crypto::hkdf_derive;
|
|
use crate::errors::ProtocolError;
|
|
use crate::identity::IdentityKeyPair;
|
|
use crate::prekey::PreKeyBundle;
|
|
|
|
/// Result of initiating X3DH (Alice's side).
|
|
pub struct X3DHInitResult {
|
|
/// The shared secret (32 bytes), used to initialize the Double Ratchet.
|
|
pub shared_secret: [u8; 32],
|
|
/// Alice's ephemeral public key (sent to Bob).
|
|
pub ephemeral_public: PublicKey,
|
|
/// Which one-time pre-key was used (if any).
|
|
pub used_one_time_pre_key_id: Option<u32>,
|
|
}
|
|
|
|
/// Initiate X3DH key exchange (Alice's side).
|
|
///
|
|
/// Alice fetches Bob's pre-key bundle from the server, performs four DH
|
|
/// operations, and derives a shared secret.
|
|
pub fn initiate(
|
|
our_identity: &IdentityKeyPair,
|
|
their_bundle: &PreKeyBundle,
|
|
) -> Result<X3DHInitResult, ProtocolError> {
|
|
// Verify the signed pre-key signature
|
|
let their_identity = ed25519_dalek::VerifyingKey::from_bytes(
|
|
&their_bundle.identity_key,
|
|
)
|
|
.map_err(|_| ProtocolError::X3DHFailed("invalid identity key".into()))?;
|
|
their_bundle
|
|
.signed_pre_key
|
|
.verify(&their_identity)
|
|
.map_err(|_| ProtocolError::X3DHFailed("signed pre-key verification failed".into()))?;
|
|
|
|
let ephemeral_secret = StaticSecret::random_from_rng(rand::rngs::OsRng);
|
|
let ephemeral_public = PublicKey::from(&ephemeral_secret);
|
|
|
|
let their_spk = PublicKey::from(their_bundle.signed_pre_key.public_key);
|
|
let their_identity_x25519 = PublicKey::from(their_bundle.identity_encryption_key);
|
|
|
|
// DH1: our_identity_x25519 * their_signed_pre_key
|
|
let dh1 = our_identity.encryption.diffie_hellman(&their_spk);
|
|
|
|
// DH2: our_ephemeral * their_identity_x25519
|
|
let dh2 = ephemeral_secret.diffie_hellman(&their_identity_x25519);
|
|
|
|
// DH3: our_ephemeral * their_signed_pre_key
|
|
let dh3 = ephemeral_secret.diffie_hellman(&their_spk);
|
|
|
|
// DH4: our_ephemeral * their_one_time_pre_key (if available)
|
|
let mut dh_concat = Vec::with_capacity(128);
|
|
dh_concat.extend_from_slice(dh1.as_bytes());
|
|
dh_concat.extend_from_slice(dh2.as_bytes());
|
|
dh_concat.extend_from_slice(dh3.as_bytes());
|
|
|
|
let used_otpk_id = if let Some(ref otpk) = their_bundle.one_time_pre_key {
|
|
let their_otpk = PublicKey::from(otpk.public_key);
|
|
let dh4 = ephemeral_secret.diffie_hellman(&their_otpk);
|
|
dh_concat.extend_from_slice(dh4.as_bytes());
|
|
Some(otpk.id)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// KDF: derive 32-byte shared secret
|
|
let mut shared_secret = [0u8; 32];
|
|
let derived = hkdf_derive(&dh_concat, b"", b"warzone-x3dh", 32);
|
|
shared_secret.copy_from_slice(&derived);
|
|
dh_concat.zeroize();
|
|
|
|
Ok(X3DHInitResult {
|
|
shared_secret,
|
|
ephemeral_public,
|
|
used_one_time_pre_key_id: used_otpk_id,
|
|
})
|
|
}
|
|
|
|
/// Respond to X3DH key exchange (Bob's side).
|
|
///
|
|
/// Bob receives Alice's ephemeral public key and performs the same DH
|
|
/// operations to derive the same shared secret.
|
|
pub fn respond(
|
|
our_identity: &IdentityKeyPair,
|
|
our_signed_pre_key_secret: &StaticSecret,
|
|
our_one_time_pre_key_secret: Option<&StaticSecret>,
|
|
their_identity_x25519: &PublicKey,
|
|
their_ephemeral_public: &PublicKey,
|
|
) -> Result<[u8; 32], ProtocolError> {
|
|
let their_eph = *their_ephemeral_public;
|
|
|
|
// DH1: our_signed_pre_key * their_identity_x25519
|
|
let dh1 = our_signed_pre_key_secret.diffie_hellman(their_identity_x25519);
|
|
|
|
// DH2: our_identity_x25519 * their_ephemeral
|
|
let dh2 = our_identity.encryption.diffie_hellman(&their_eph);
|
|
|
|
// DH3: their_ephemeral * our_signed_pre_key
|
|
let dh3 = our_signed_pre_key_secret.diffie_hellman(&their_eph);
|
|
|
|
let mut dh_concat = Vec::with_capacity(128);
|
|
dh_concat.extend_from_slice(dh1.as_bytes());
|
|
dh_concat.extend_from_slice(dh2.as_bytes());
|
|
dh_concat.extend_from_slice(dh3.as_bytes());
|
|
|
|
if let Some(otpk) = our_one_time_pre_key_secret {
|
|
let dh4 = otpk.diffie_hellman(&their_eph);
|
|
dh_concat.extend_from_slice(dh4.as_bytes());
|
|
}
|
|
|
|
let mut shared_secret = [0u8; 32];
|
|
let derived = hkdf_derive(&dh_concat, b"", b"warzone-x3dh", 32);
|
|
shared_secret.copy_from_slice(&derived);
|
|
dh_concat.zeroize();
|
|
|
|
Ok(shared_secret)
|
|
}
|
|
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::identity::Seed;
|
|
use crate::prekey::{generate_one_time_pre_keys, generate_signed_pre_key};
|
|
|
|
#[test]
|
|
fn x3dh_shared_secret_matches() {
|
|
let alice_seed = Seed::generate();
|
|
let alice_id = alice_seed.derive_identity();
|
|
|
|
let bob_seed = Seed::generate();
|
|
let bob_id = bob_seed.derive_identity();
|
|
|
|
let (bob_spk_secret, bob_spk) = generate_signed_pre_key(&bob_id, 1);
|
|
let bob_otpks = generate_one_time_pre_keys(0, 1);
|
|
let bob_pub = bob_id.public_identity();
|
|
|
|
let alice_pub = alice_id.public_identity();
|
|
|
|
let 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(crate::prekey::OneTimePreKeyPublic {
|
|
id: bob_otpks[0].id,
|
|
public_key: *bob_otpks[0].public.as_bytes(),
|
|
}),
|
|
};
|
|
|
|
let alice_result = initiate(&alice_id, &bundle).unwrap();
|
|
let bob_secret = respond(
|
|
&bob_id,
|
|
&bob_spk_secret,
|
|
Some(&bob_otpks[0].secret),
|
|
&alice_pub.encryption,
|
|
&alice_result.ephemeral_public,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(alice_result.shared_secret, bob_secret);
|
|
}
|
|
}
|