feat: SAS (Short Authentication String) for call identity verification
Some checks failed
Mirror to GitHub / mirror (push) Failing after 35s
Build Release Binaries / build-amd64 (push) Failing after 3m19s

Derive a 4-digit code from the shared DH secret via HKDF with label
"warzone-sas-code". Both peers compute the same code; a MITM relay
produces a different one. Users compare verbally during the call.

- CryptoSession::sas_code() -> Option<u32> on the trait
- ChaChaSession stores and returns the SAS
- HKDF derivation in WarzoneKeyExchange::derive_session()
- Tests: both peers match, MITM produces different code

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-04-09 05:48:08 +04:00
parent 39ac181d63
commit d27e85ecf2
3 changed files with 75 additions and 1 deletions

View File

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

View File

@@ -26,6 +26,8 @@ pub struct ChaChaSession {
rekey_mgr: RekeyManager,
/// Pending ephemeral secret for rekey (stored until peer responds).
pending_rekey_secret: Option<StaticSecret>,
/// Short Authentication String (4-digit code for verbal verification).
sas_code: Option<u32>,
}
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<u32> {
self.sas_code
}
}
#[cfg(test)]