diff --git a/crates/wzp-crypto/src/handshake.rs b/crates/wzp-crypto/src/handshake.rs index 1e65c48..f2a4a19 100644 --- a/crates/wzp-crypto/src/handshake.rs +++ b/crates/wzp-crypto/src/handshake.rs @@ -110,7 +110,18 @@ impl KeyExchange for WarzoneKeyExchange { hk.expand(b"warzone-session-key", &mut session_key) .expect("HKDF expand for session key should not fail"); - Ok(Box::new(ChaChaSession::new(session_key))) + // Derive SAS (Short Authentication String) from shared secret only. + // The shared secret is identical on both sides (X25519 DH property). + // A MITM would produce a different shared secret → different SAS. + // We use a dedicated HKDF label so SAS is independent of the session key. + let mut sas_key = [0u8; 4]; + hk.expand(b"warzone-sas-code", &mut sas_key) + .expect("HKDF expand for SAS should not fail"); + let sas_code = u32::from_be_bytes(sas_key) % 10000; + + let mut session = ChaChaSession::new(session_key); + session.set_sas(sas_code); + Ok(Box::new(session)) } } @@ -211,4 +222,47 @@ mod tests { assert_eq!(&decrypted, plaintext); } + + #[test] + fn sas_codes_match_between_peers() { + let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]); + let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]); + + let alice_eph_pub = alice.generate_ephemeral(); + let bob_eph_pub = bob.generate_ephemeral(); + + let alice_session = alice.derive_session(&bob_eph_pub).unwrap(); + let bob_session = bob.derive_session(&alice_eph_pub).unwrap(); + + let alice_sas = alice_session.sas_code(); + let bob_sas = bob_session.sas_code(); + + assert!(alice_sas.is_some(), "Alice should have SAS"); + assert!(bob_sas.is_some(), "Bob should have SAS"); + assert_eq!(alice_sas, bob_sas, "SAS codes must match between peers"); + assert!(alice_sas.unwrap() < 10000, "SAS should be 4 digits"); + } + + #[test] + fn sas_differs_for_different_peers() { + let mut alice = WarzoneKeyExchange::from_identity_seed(&[0xAA; 32]); + let mut bob = WarzoneKeyExchange::from_identity_seed(&[0xBB; 32]); + let mut eve = WarzoneKeyExchange::from_identity_seed(&[0xEE; 32]); + + let alice_eph = alice.generate_ephemeral(); + let bob_eph = bob.generate_ephemeral(); + let eve_eph = eve.generate_ephemeral(); + + let alice_bob_session = alice.derive_session(&bob_eph).unwrap(); + + // Eve does separate handshake with Bob (MITM scenario) + let eve_bob_session = eve.derive_session(&bob_eph).unwrap(); + + // SAS codes should differ — Eve's session has different shared secret + assert_ne!( + alice_bob_session.sas_code(), + eve_bob_session.sas_code(), + "MITM session should produce different SAS" + ); + } } diff --git a/crates/wzp-crypto/src/session.rs b/crates/wzp-crypto/src/session.rs index c9a15f8..bba005f 100644 --- a/crates/wzp-crypto/src/session.rs +++ b/crates/wzp-crypto/src/session.rs @@ -26,6 +26,8 @@ pub struct ChaChaSession { rekey_mgr: RekeyManager, /// Pending ephemeral secret for rekey (stored until peer responds). pending_rekey_secret: Option, + /// Short Authentication String (4-digit code for verbal verification). + sas_code: Option, } impl ChaChaSession { @@ -46,9 +48,15 @@ impl ChaChaSession { recv_seq: 0, rekey_mgr: RekeyManager::new(shared_secret), pending_rekey_secret: None, + sas_code: None, } } + /// Set the SAS code (called by key exchange after derivation). + pub fn set_sas(&mut self, code: u32) { + self.sas_code = Some(code); + } + /// Install a new key (after rekeying). fn install_key(&mut self, new_key: [u8; 32]) { use sha2::Digest; @@ -136,6 +144,10 @@ impl CryptoSession for ChaChaSession { Ok(()) } + + fn sas_code(&self) -> Option { + self.sas_code + } } #[cfg(test)] diff --git a/crates/wzp-proto/src/traits.rs b/crates/wzp-proto/src/traits.rs index 1e5c666..752984d 100644 --- a/crates/wzp-proto/src/traits.rs +++ b/crates/wzp-proto/src/traits.rs @@ -132,6 +132,14 @@ pub trait CryptoSession: Send + Sync { fn overhead(&self) -> usize { 16 // ChaCha20-Poly1305 tag } + + /// Short Authentication String (SAS) — 4-digit code for verbal verification. + /// Both peers derive the same code from the shared secret + identity keys. + /// If a MITM relay is intercepting, the codes will differ. + /// Returns None if SAS was not computed (e.g., relay-side sessions). + fn sas_code(&self) -> Option { + None + } } /// Key exchange using the Warzone identity model.