Skip to main content

huddle_core/crypto/
sas.rs

1//! Short-Authentication-String (SAS) verification — Phase G.
2//!
3//! Two peers OOB-compare a short derived code to confirm they each
4//! hold the matching Ed25519 keys (defense against MITM during initial
5//! contact, before fingerprint trust is established).
6//!
7//! Protocol shape (each step is a signed `RoomMessage` on the room's
8//! gossipsub topic):
9//!
10//! 1. Initiator picks a random 16-byte `tx_id` + an ephemeral X25519
11//!    keypair. Sends `SasInit { tx_id, ephemeral_x25519_pubkey, target_fp }`.
12//! 2. Responder generates their own ephemeral X25519 keypair, computes
13//!    ECDH with the initiator's pubkey, derives the SAS code via
14//!    `derive_sas_code(shared, tx_id)`, and replies with
15//!    `SasResponse { tx_id, ephemeral_x25519_pubkey }`. The responder
16//!    sees the code locally and shows it.
17//! 3. The initiator computes ECDH the other direction, derives the
18//!    same code, shows it.
19//! 4. Both users compare codes OOB. Each side presses Match → broadcasts
20//!    `SasConfirm { tx_id, matched: true }`.
21//! 5. On receiving the other side's `matched=true`, set the partner's
22//!    fingerprint as `verified=true` (per-room + global `verified_peers`).
23//!
24//! The signatures on each envelope bind the ephemeral X25519 pubkeys to
25//! the sender's Ed25519 identity. A MITM who substitutes their own
26//! ephemeral key into the exchange ends up with a *different* SAS code
27//! than the legitimate peer would compute, so the OOB comparison fails.
28
29use hkdf::Hkdf;
30use rand::RngCore;
31use sha2::Sha256;
32use x25519_dalek::{PublicKey, StaticSecret};
33
34use crate::error::{HuddleError, Result};
35
36/// Length of the transaction id used as HKDF salt. 16 bytes (128 bits)
37/// is plenty of unforgeability; sized to be base64-friendly.
38pub const TX_ID_LEN: usize = 16;
39
40/// SAS code information given to both sides for OOB comparison.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SasCode {
43    /// 6 emoji indices into [`SAS_EMOJI`] (each 0..64). Human-friendly
44    /// for visual comparison; works in any modern terminal with emoji
45    /// support.
46    pub emoji_indices: [u8; 6],
47    /// 6-decimal-digit version. Fallback for terminals without emoji
48    /// rendering and easier to read aloud over a noisy call.
49    pub decimal: String,
50}
51
52impl SasCode {
53    pub fn emoji_string(&self) -> String {
54        self.emoji_indices
55            .iter()
56            .map(|i| SAS_EMOJI[*i as usize].0)
57            .collect::<Vec<_>>()
58            .join(" ")
59    }
60
61    pub fn emoji_labels(&self) -> String {
62        self.emoji_indices
63            .iter()
64            .map(|i| SAS_EMOJI[*i as usize].1)
65            .collect::<Vec<_>>()
66            .join(" / ")
67    }
68}
69
70/// Fresh X25519 ephemeral keypair + random tx_id. The secret stays on
71/// the initiator's machine until the SAS finishes; the pubkey is
72/// transmitted in the signed envelope.
73pub fn new_session() -> ([u8; TX_ID_LEN], StaticSecret, PublicKey) {
74    let mut tx_id = [0u8; TX_ID_LEN];
75    rand::thread_rng().fill_bytes(&mut tx_id);
76    // StaticSecret here is the X25519 "long-term" type from x25519-dalek;
77    // we use it as ephemeral (drop after the SAS). Need the
78    // `static_secrets` feature flag because the `EphemeralSecret` type
79    // is more restrictive in v2 — `StaticSecret` lets us hold onto it
80    // across a few async hops.
81    let secret = StaticSecret::random_from_rng(rand::thread_rng());
82    let public = PublicKey::from(&secret);
83    (tx_id, secret, public)
84}
85
86/// Derive the 6-emoji + 6-digit SAS code from the X25519 shared secret
87/// and the agreed-upon `tx_id`. Both peers compute this independently
88/// and must end up with the same answer for OOB comparison to succeed.
89pub fn derive_sas_code(
90    our_secret: &StaticSecret,
91    their_public: &PublicKey,
92    tx_id: &[u8; TX_ID_LEN],
93) -> SasCode {
94    let shared = our_secret.diffie_hellman(their_public);
95    // HKDF over the shared secret. tx_id as salt prevents replay
96    // (two SAS flows between the same pair must produce different
97    // codes); info domain-separates from any other HKDF use.
98    let hk = Hkdf::<Sha256>::new(Some(tx_id), shared.as_bytes());
99    // 6 emoji bytes (each in 0..64 → 6 bits each = 36 bits) +
100    // 3 bytes for the 6-digit decimal (~24 bits, plenty).
101    let mut okm = [0u8; 9];
102    hk.expand(b"huddle-sas-v1", &mut okm)
103        .expect("9 bytes is well within HKDF output limit");
104    let mut emoji_indices = [0u8; 6];
105    for i in 0..6 {
106        emoji_indices[i] = okm[i] & 0x3f; // mask to 0..64
107    }
108    let decimal = format!(
109        "{:06}",
110        (u32::from(okm[6]) << 16 | u32::from(okm[7]) << 8 | u32::from(okm[8])) % 1_000_000
111    );
112    SasCode {
113        emoji_indices,
114        decimal,
115    }
116}
117
118/// 64 emoji / label pairs — every entry is visually distinct and the
119/// label is a short English word so users can also read the code aloud.
120/// Lifted in spirit from Matrix MSC 2241; reordered + relabelled to
121/// keep words ASCII and one-syllable where possible.
122pub const SAS_EMOJI: [(&str, &str); 64] = [
123    ("🐶", "dog"),
124    ("🐱", "cat"),
125    ("🦁", "lion"),
126    ("🐴", "horse"),
127    ("🦄", "unicorn"),
128    ("🐷", "pig"),
129    ("🐘", "elephant"),
130    ("🐰", "rabbit"),
131    ("🐼", "panda"),
132    ("🐔", "rooster"),
133    ("🐧", "penguin"),
134    ("🐢", "turtle"),
135    ("🐟", "fish"),
136    ("🐙", "octopus"),
137    ("🦋", "butterfly"),
138    ("🌷", "flower"),
139    ("🌳", "tree"),
140    ("🌵", "cactus"),
141    ("🍄", "mushroom"),
142    ("🌍", "globe"),
143    ("🌙", "moon"),
144    ("☁️", "cloud"),
145    ("🔥", "fire"),
146    ("🍌", "banana"),
147    ("🍎", "apple"),
148    ("🍓", "strawberry"),
149    ("🌽", "corn"),
150    ("🍕", "pizza"),
151    ("🎂", "cake"),
152    ("❤️", "heart"),
153    ("🙂", "smiley"),
154    ("🤖", "robot"),
155    ("🎩", "hat"),
156    ("👓", "glasses"),
157    ("🔧", "spanner"),
158    ("🎅", "santa"),
159    ("👍", "thumbs up"),
160    ("☂️", "umbrella"),
161    ("⌛", "hourglass"),
162    ("⏰", "clock"),
163    ("🎁", "gift"),
164    ("💡", "lightbulb"),
165    ("📕", "book"),
166    ("✏️", "pencil"),
167    ("📎", "paperclip"),
168    ("✂️", "scissors"),
169    ("🔒", "lock"),
170    ("🔑", "key"),
171    ("🔨", "hammer"),
172    ("☎️", "telephone"),
173    ("🏁", "flag"),
174    ("🚂", "train"),
175    ("🚲", "bicycle"),
176    ("✈️", "plane"),
177    ("🚀", "rocket"),
178    ("🏆", "trophy"),
179    ("⚽", "ball"),
180    ("🎸", "guitar"),
181    ("🎺", "trumpet"),
182    ("🔔", "bell"),
183    ("⚓", "anchor"),
184    ("🎧", "headphones"),
185    ("📁", "folder"),
186    ("📌", "pin"),
187];
188
189/// Decode a base64-encoded 32-byte X25519 pubkey received over the wire.
190pub fn parse_pubkey(b64: &str) -> Result<PublicKey> {
191    use base64::engine::general_purpose::STANDARD as B64;
192    use base64::Engine;
193    let bytes = B64
194        .decode(b64)
195        .map_err(|e| HuddleError::Session(format!("bad x25519 pubkey b64: {e}")))?;
196    if bytes.len() != 32 {
197        return Err(HuddleError::Session(format!(
198            "x25519 pubkey is {} bytes, expected 32",
199            bytes.len()
200        )));
201    }
202    let mut arr = [0u8; 32];
203    arr.copy_from_slice(&bytes);
204    Ok(PublicKey::from(arr))
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn both_sides_derive_same_code() {
213        let (tx_id, alice_secret, alice_pub) = new_session();
214        let (_, bob_secret, bob_pub) = new_session();
215
216        let alice_code = derive_sas_code(&alice_secret, &bob_pub, &tx_id);
217        let bob_code = derive_sas_code(&bob_secret, &alice_pub, &tx_id);
218        assert_eq!(alice_code, bob_code);
219        assert_eq!(alice_code.decimal.len(), 6);
220        assert!(alice_code.decimal.chars().all(|c| c.is_ascii_digit()));
221        // Indices must all be in 0..64.
222        for i in alice_code.emoji_indices {
223            assert!((i as usize) < SAS_EMOJI.len());
224        }
225    }
226
227    #[test]
228    fn different_tx_id_yields_different_code() {
229        let (tx_id_a, alice_secret, _) = new_session();
230        let (_, bob_secret, bob_pub) = new_session();
231        let alice_code = derive_sas_code(&alice_secret, &bob_pub, &tx_id_a);
232
233        let mut tx_id_b = tx_id_a;
234        tx_id_b[0] ^= 0xff;
235        let alice_code_b = derive_sas_code(&alice_secret, &bob_pub, &tx_id_b);
236        let _ = bob_secret;
237        assert_ne!(alice_code, alice_code_b);
238    }
239
240    #[test]
241    fn mitm_substitute_yields_different_code() {
242        // Mallory MITMs: Alice's traffic to Bob is replaced with
243        // Mallory's pubkey, and vice versa. Alice computes ECDH with
244        // Mallory's pub; Bob computes ECDH with Mallory's pub. Their
245        // SAS codes will both differ from each other and from a
246        // legitimate same-pubkey-pair derivation — so OOB comparison
247        // catches the attack.
248        let (tx_id, alice_secret, alice_pub) = new_session();
249        let (_, bob_secret, bob_pub) = new_session();
250        let (_, _mallory_secret, mallory_pub) = new_session();
251
252        let alice_thinks_bob = derive_sas_code(&alice_secret, &mallory_pub, &tx_id);
253        let bob_thinks_alice = derive_sas_code(&bob_secret, &mallory_pub, &tx_id);
254        assert_ne!(alice_thinks_bob, bob_thinks_alice);
255
256        // Sanity: without MITM, both sides agree.
257        let alice_real = derive_sas_code(&alice_secret, &bob_pub, &tx_id);
258        let bob_real = derive_sas_code(&bob_secret, &alice_pub, &tx_id);
259        assert_eq!(alice_real, bob_real);
260    }
261
262    #[test]
263    fn pubkey_round_trip() {
264        let (_, _, pub_) = new_session();
265        use base64::engine::general_purpose::STANDARD as B64;
266        use base64::Engine;
267        let encoded = B64.encode(pub_.as_bytes());
268        let decoded = parse_pubkey(&encoded).unwrap();
269        assert_eq!(decoded.as_bytes(), pub_.as_bytes());
270    }
271}