Skip to main content

huddle_protocol/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//!
29//! ## SAS table — a huddle-internal scheme (NOT Matrix wire-compatible)
30//!
31//! huddle uses its own 49-emoji subset of the Matrix MSC 2241 list under a
32//! huddle-specific HKDF info string (`b"huddle-sas-v1"`) and maps 6-bit chunks
33//! into 0..49 by **rejection sampling** (see `derive_emoji_indices_rejection`).
34//! This does **not** interoperate with Matrix SAS: canonical MSC 2241 uses a
35//! 64-entry table indexed directly by each 6-bit chunk (no modulus, no
36//! rejection sampling) under its own info string, so a Matrix client would
37//! derive entirely different emoji. The derivation produces 7 emoji (42 bits /
38//! 6 = 7 chunks) and 3 four-digit decimal groups (39 bits / 13 = 3 chunks, each
39//! offset +1000 so values land in 1000..=9191); the decimal shape matches the
40//! MSC 2241 decimal SAS, but the emoji scheme is huddle↔huddle only. Since both
41//! peers run identical deterministic code, MITM detection is unaffected.
42//!
43//! ## huddle 2.0: optional post-quantum capability binding
44//!
45//! [`derive_sas_code`] takes both peers' ML-KEM encapsulation keys
46//! (`our_mlkem_ek`, `their_mlkem_ek`). The binding is **gated on the partner's**
47//! key: when we hold a pinned ML-KEM ek for the peer (`their_mlkem_ek = Some`),
48//! the transcript mixes a `b"huddle-sas-pqbind-v1"` domain tag plus
49//! `SHA-256` of **both** eks concatenated **in byte-sorted order** into the HKDF
50//! `info` — so the pair's *PQ capability* becomes part of the out-of-band trust
51//! anchor. Sorting makes the binding symmetric: each peer sees the two keys in
52//! the opposite (our, their) roles, but sorting yields identical `info` on both
53//! sides, so two honest PQ-capable peers derive the *same* code. A relay that
54//! strips the ML-KEM pubkey from one side's announce drives that side's
55//! `their_mlkem_ek` to `None` (classical transcript) while the other still
56//! binds; the two SAS codes then diverge and the OOB comparison catches the
57//! silent classical downgrade. The salt (`tx_id`) and PRF (HKDF-SHA256) are
58//! unchanged — only the `info` domain tag + hash are added. When the *partner*
59//! has no pinned ek (`their_mlkem_ek = None` — group members, pre-1.3 partners,
60//! or the classical fallback) the derivation is byte-for-byte identical to the
61//! 1.x `b"huddle-sas-v1"` transcript regardless of our own key, so old and new
62//! peers still agree.
63
64use hkdf::Hkdf;
65use rand::RngCore;
66use sha2::{Digest, Sha256};
67use x25519_dalek::{PublicKey, StaticSecret};
68
69use crate::error::{ProtocolError, Result};
70
71/// Length of the transaction id used as HKDF salt. 16 bytes (128 bits)
72/// is plenty of unforgeability; sized to be base64-friendly.
73pub const TX_ID_LEN: usize = 16;
74
75/// HKDF `info` for the classical (no PQ binding) SAS transcript. Frozen since
76/// huddle 0.7 — kept byte-for-byte so a `partner_mlkem_ek = None` derivation
77/// stays compatible with every prior release.
78const SAS_INFO_V1: &[u8] = b"huddle-sas-v1";
79
80/// huddle 2.0: domain tag prefixed to `SHA-256(partner_mlkem_ek)` when the SAS
81/// transcript binds the partner's post-quantum (ML-KEM) capability. Distinct
82/// from [`SAS_INFO_V1`] so a bound and an unbound derivation can never collide.
83const SAS_INFO_PQBIND: &[u8] = b"huddle-sas-pqbind-v1";
84
85/// SAS code information given to both sides for OOB comparison.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct SasCode {
88    /// 7 emoji indices into [`SAS_EMOJI`] (each 0..49). Human-friendly
89    /// for visual comparison; works in any modern terminal with emoji
90    /// support. Matches Matrix MSC 2241 shape.
91    pub emoji_indices: [u8; 7],
92    /// Three 4-digit groups separated by `-`, each in `1000..=9191`,
93    /// per MSC 2241. Easier to read aloud than a flat 7-digit number.
94    pub decimal: String,
95}
96
97impl SasCode {
98    pub fn emoji_string(&self) -> String {
99        self.emoji_indices
100            .iter()
101            .map(|i| SAS_EMOJI[*i as usize].0)
102            .collect::<Vec<_>>()
103            .join(" ")
104    }
105
106    pub fn emoji_labels(&self) -> String {
107        self.emoji_indices
108            .iter()
109            .map(|i| SAS_EMOJI[*i as usize].1)
110            .collect::<Vec<_>>()
111            .join(" / ")
112    }
113}
114
115/// Fresh X25519 ephemeral keypair + random tx_id. The secret stays on
116/// the initiator's machine until the SAS finishes; the pubkey is
117/// transmitted in the signed envelope.
118pub fn new_session() -> ([u8; TX_ID_LEN], StaticSecret, PublicKey) {
119    let mut tx_id = [0u8; TX_ID_LEN];
120    rand::thread_rng().fill_bytes(&mut tx_id);
121    // StaticSecret here is the X25519 "long-term" type from x25519-dalek;
122    // we use it as ephemeral (drop after the SAS). Need the
123    // `static_secrets` feature flag because the `EphemeralSecret` type
124    // is more restrictive in v2 — `StaticSecret` lets us hold onto it
125    // across a few async hops.
126    let secret = StaticSecret::random_from_rng(rand::thread_rng());
127    let public = PublicKey::from(&secret);
128    (tx_id, secret, public)
129}
130
131/// Derive the 7-emoji + 3-group-decimal SAS code from the X25519
132/// shared secret and the agreed-upon `tx_id`. Both peers compute this
133/// independently and must end up with the same answer for OOB
134/// comparison to succeed.
135///
136/// Matches the MSC 2241 SAS *shape* (not wire-compatible — see the module
137/// doc): HKDF-SHA256 with `tx_id` as salt and the SAS info string as info,
138/// expanded to 11 bytes. First 6 bytes → 7 6-bit chunks, rejection-sampled
139/// into 0..49 → emoji indices. Next 5 bytes → 3 13-bit chunks (+ 1000) → 3
140/// four-digit decimal groups.
141///
142/// `our_mlkem_ek` / `their_mlkem_ek` are huddle 2.0's optional post-quantum
143/// capability binding (see the module doc). The binding is gated on the
144/// **partner's** key: pass `their_mlkem_ek = Some(ek)` when we hold the peer's
145/// pinned ML-KEM encapsulation key, and `our_mlkem_ek = Some(ek)` for our own
146/// (always available for a 2.0 identity). Both eks are then folded into the HKDF
147/// `info` as `domain-tag || SHA-256(sorted(our_ek, their_ek))`, anchoring the
148/// pair's PQ capability into the verified SAS so a relay can't silently
149/// downgrade them to classical-only. Pass `their_mlkem_ek = None` for group
150/// members, pre-1.3 partners, or the classical fallback; that path is
151/// byte-for-byte identical to the 1.x derivation regardless of `our_mlkem_ek`.
152/// Because the eks are sorted, both peers derive the same code without needing
153/// to agree on an order.
154pub fn derive_sas_code(
155    our_secret: &StaticSecret,
156    their_public: &PublicKey,
157    tx_id: &[u8; TX_ID_LEN],
158    our_mlkem_ek: Option<&[u8]>,
159    their_mlkem_ek: Option<&[u8]>,
160) -> Result<SasCode> {
161    let shared = our_secret.diffie_hellman(their_public);
162    // huddle 1.1.4: reject a non-contributory (small-order) peer ephemeral.
163    // Such a "pubkey" forces a predictable shared secret, which would let a
164    // MITM steer both sides to a derivable SAS code and defeat the OOB
165    // comparison. Honest peers always produce a contributory secret.
166    if !shared.was_contributory() {
167        return Err(ProtocolError::Session(
168            "SAS rejected: peer X25519 ephemeral is non-contributory (small-order point)".into(),
169        ));
170    }
171    // HKDF over the shared secret. tx_id as salt prevents replay
172    // (two SAS flows between the same pair must produce different
173    // codes); info domain-separates from any other HKDF use and — in
174    // huddle 2.0 — optionally binds the partner's ML-KEM capability.
175    let hk = Hkdf::<Sha256>::new(Some(tx_id), shared.as_bytes());
176    let info = sas_info(our_mlkem_ek, their_mlkem_ek);
177    let mut okm = [0u8; 11];
178    hk.expand(&info, &mut okm)
179        .expect("11 bytes is well within HKDF output limit");
180
181    // First 6 bytes = 48 bits. Use the high 42 bits (7 × 6) for emoji.
182    // Bit extraction (big-endian, MSB-first):
183    let b = &okm[..6];
184    let mut raw_emoji = [0u8; 7];
185    raw_emoji[0] = b[0] >> 2;
186    raw_emoji[1] = ((b[0] & 0x03) << 4) | (b[1] >> 4);
187    raw_emoji[2] = ((b[1] & 0x0f) << 2) | (b[2] >> 6);
188    raw_emoji[3] = b[2] & 0x3f;
189    raw_emoji[4] = b[3] >> 2;
190    raw_emoji[5] = ((b[3] & 0x03) << 4) | (b[4] >> 4);
191    raw_emoji[6] = ((b[4] & 0x0f) << 2) | (b[5] >> 6);
192    // huddle 0.7.11: rejection sampling instead of `raw % 49`.
193    // 6-bit values in 0..64 mod 49 makes indices 0..14 twice as likely
194    // (hit by raw 0..14 AND raw 49..63), measurably under-sampling the
195    // 49^7 SAS space and reducing effective entropy. Now we expand
196    // additional HKDF output to refill any byte that falls in 49..63
197    // — the canonical MSC 2241 approach. The expansion is cheap and
198    // deterministic, so both sides still derive the same code.
199    let emoji_indices = derive_emoji_indices_rejection(&hk, raw_emoji);
200
201    // Bytes 6..11 = 40 bits. Use the high 39 bits for the decimal
202    // (3 × 13-bit chunks, each offset by 1000).
203    let d = &okm[6..11];
204    let chunk0 = ((u32::from(d[0]) << 5) | (u32::from(d[1]) >> 3)) & 0x1fff;
205    let chunk1 =
206        ((u32::from(d[1] & 0x07) << 10) | (u32::from(d[2]) << 2) | (u32::from(d[3]) >> 6)) & 0x1fff;
207    let chunk2 = ((u32::from(d[3] & 0x3f) << 7) | (u32::from(d[4]) >> 1)) & 0x1fff;
208    let decimal = format!("{}-{}-{}", chunk0 + 1000, chunk1 + 1000, chunk2 + 1000);
209
210    Ok(SasCode {
211        emoji_indices,
212        decimal,
213    })
214}
215
216/// Build the HKDF `info` for the main SAS expansion.
217///
218/// Gated on the **partner's** key: `their_mlkem_ek = None` reproduces the
219/// classical 1.x transcript byte-for-byte ([`SAS_INFO_V1`]) regardless of
220/// `our_mlkem_ek`, so a PQ-capable peer talking to a classical/group/pre-1.3
221/// partner still agrees with them. When `their_mlkem_ek = Some`, the binding
222/// concatenates the [`SAS_INFO_PQBIND`] domain tag with `SHA-256` of every
223/// present ek in **byte-sorted** order — so each peer, which holds the two keys
224/// in the opposite (our, their) roles, produces identical `info` and the two
225/// SAS codes match. A bound derivation can never collide with an unbound one
226/// (distinct domain tag), and stripping the ML-KEM key from one side flips that
227/// side to the classical transcript, making the codes diverge (see the module
228/// doc). Only the `info` changes; the `tx_id` salt and the HKDF-SHA256 PRF are
229/// untouched.
230fn sas_info(our_mlkem_ek: Option<&[u8]>, their_mlkem_ek: Option<&[u8]>) -> Vec<u8> {
231    // Gate strictly on the partner's pinned capability.
232    let their_ek = match their_mlkem_ek {
233        None => return SAS_INFO_V1.to_vec(),
234        Some(ek) => ek,
235    };
236    // Symmetric binding: hash both present eks in a canonical (byte-sorted)
237    // order so both peers — who see the keys in opposite roles — agree.
238    let mut eks: Vec<&[u8]> = Vec::with_capacity(2);
239    if let Some(ours) = our_mlkem_ek {
240        eks.push(ours);
241    }
242    eks.push(their_ek);
243    eks.sort_unstable();
244    let mut hasher = Sha256::new();
245    for ek in &eks {
246        hasher.update(ek);
247    }
248    let digest = hasher.finalize();
249    let mut info = Vec::with_capacity(SAS_INFO_PQBIND.len() + digest.len());
250    info.extend_from_slice(SAS_INFO_PQBIND);
251    info.extend_from_slice(&digest);
252    info
253}
254
255/// huddle's 49-emoji subset of the Matrix MSC 2241 list, English labels.
256/// Indices 0-48; the derivation above rejection-samples 6-bit HKDF chunks
257/// into 0..49 (not Matrix's direct 6-bit indexing — see the module doc).
258pub const SAS_EMOJI: [(&str, &str); 49] = [
259    ("🐶", "dog"),
260    ("🐱", "cat"),
261    ("🦁", "lion"),
262    ("🐎", "horse"),
263    ("🦄", "unicorn"),
264    ("🐷", "pig"),
265    ("🐘", "elephant"),
266    ("🐰", "rabbit"),
267    ("🐼", "panda"),
268    ("🐓", "rooster"),
269    ("🐧", "penguin"),
270    ("🐢", "turtle"),
271    ("🐟", "fish"),
272    ("🐙", "octopus"),
273    ("🦋", "butterfly"),
274    ("🌷", "flower"),
275    ("🌳", "tree"),
276    ("🌵", "cactus"),
277    ("🍄", "mushroom"),
278    ("🌏", "globe"),
279    ("🌙", "moon"),
280    ("☁️", "cloud"),
281    ("🔥", "fire"),
282    ("🍌", "banana"),
283    ("🍎", "apple"),
284    ("🍓", "strawberry"),
285    ("🌽", "corn"),
286    ("🍕", "pizza"),
287    ("🎂", "cake"),
288    ("❤️", "heart"),
289    ("🙂", "smiley"),
290    ("🤖", "robot"),
291    ("🎩", "hat"),
292    ("👓", "glasses"),
293    ("🔧", "spanner"),
294    ("🎅", "santa"),
295    ("👍", "thumbs up"),
296    ("☂️", "umbrella"),
297    ("⌛", "hourglass"),
298    ("⏰", "clock"),
299    ("🎁", "gift"),
300    ("💡", "light bulb"),
301    ("📕", "book"),
302    ("✏️", "pencil"),
303    ("📎", "paperclip"),
304    ("✂️", "scissors"),
305    ("🔒", "lock"),
306    ("🔑", "key"),
307    ("🔨", "hammer"),
308];
309
310/// huddle 0.7.11: rejection-sampling emoji-index derivation. Refills any
311/// index ≥ 49 with deterministic additional HKDF expansion so the
312/// distribution over the 49-element table is uniform.
313fn derive_emoji_indices_rejection(hk: &Hkdf<Sha256>, initial: [u8; 7]) -> [u8; 7] {
314    let mut out = [0u8; 7];
315    let mut accepted = 0usize;
316    // Use the initial bytes first.
317    for &v in &initial {
318        if v < 49 {
319            out[accepted] = v;
320            accepted += 1;
321            if accepted == 7 {
322                return out;
323            }
324        }
325    }
326    // Refill by expanding additional 6-bit chunks. We pull in 6-byte
327    // blocks of HKDF output, each yielding 8 candidate 6-bit values
328    // (high-bit pair discarded — each byte gives one 6-bit candidate
329    // via `v & 0x3f`). The info string includes a salt counter so
330    // multiple refills don't repeat the same bytes.
331    let mut counter: u32 = 0;
332    while accepted < 7 {
333        let info = {
334            let mut buf = [0u8; 24];
335            buf[..16].copy_from_slice(b"huddle-sas-v1-rs");
336            buf[16..20].copy_from_slice(&counter.to_be_bytes());
337            buf
338        };
339        let mut block = [0u8; 32];
340        if hk.expand(&info, &mut block).is_err() {
341            // The expander only fails when len > 255 * HashLen (8160
342            // bytes for SHA-256); 32 is far under, so this branch is
343            // unreachable in practice. Fall back to modulo if it
344            // somehow happens — degrades to pre-0.7.11 behavior but
345            // never panics or hangs.
346            for v in &mut initial.iter().copied() {
347                if accepted < 7 {
348                    out[accepted] = v % 49;
349                    accepted += 1;
350                }
351            }
352            break;
353        }
354        for &byte in block.iter() {
355            let candidate = byte & 0x3f;
356            if candidate < 49 {
357                out[accepted] = candidate;
358                accepted += 1;
359                if accepted == 7 {
360                    return out;
361                }
362            }
363        }
364        counter += 1;
365    }
366    out
367}
368
369/// Decode a base64-encoded 32-byte X25519 pubkey received over the wire.
370pub fn parse_pubkey(b64: &str) -> Result<PublicKey> {
371    use base64::engine::general_purpose::STANDARD as B64;
372    use base64::Engine;
373    let bytes = B64
374        .decode(b64)
375        .map_err(|e| ProtocolError::Session(format!("bad x25519 pubkey b64: {e}")))?;
376    if bytes.len() != 32 {
377        return Err(ProtocolError::Session(format!(
378            "x25519 pubkey is {} bytes, expected 32",
379            bytes.len()
380        )));
381    }
382    let mut arr = [0u8; 32];
383    arr.copy_from_slice(&bytes);
384    Ok(PublicKey::from(arr))
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390
391    #[test]
392    fn both_sides_derive_same_code() {
393        let (tx_id, alice_secret, alice_pub) = new_session();
394        let (_, bob_secret, bob_pub) = new_session();
395
396        let alice_code = derive_sas_code(&alice_secret, &bob_pub, &tx_id, None, None).unwrap();
397        let bob_code = derive_sas_code(&bob_secret, &alice_pub, &tx_id, None, None).unwrap();
398        assert_eq!(alice_code, bob_code);
399        // Decimal shape: three 4-digit groups joined by '-', each in
400        // [1000, 9191].
401        let parts: Vec<&str> = alice_code.decimal.split('-').collect();
402        assert_eq!(parts.len(), 3);
403        for p in parts {
404            assert_eq!(p.len(), 4);
405            let n: u32 = p.parse().unwrap();
406            assert!((1000..=9191).contains(&n));
407        }
408        // Indices must all be in 0..49 (MSC 2241 table size).
409        for i in alice_code.emoji_indices {
410            assert!((i as usize) < SAS_EMOJI.len());
411        }
412    }
413
414    #[test]
415    fn different_tx_id_yields_different_code() {
416        let (tx_id_a, alice_secret, _) = new_session();
417        let (_, bob_secret, bob_pub) = new_session();
418        let alice_code = derive_sas_code(&alice_secret, &bob_pub, &tx_id_a, None, None).unwrap();
419
420        let mut tx_id_b = tx_id_a;
421        tx_id_b[0] ^= 0xff;
422        let alice_code_b = derive_sas_code(&alice_secret, &bob_pub, &tx_id_b, None, None).unwrap();
423        let _ = bob_secret;
424        assert_ne!(alice_code, alice_code_b);
425    }
426
427    #[test]
428    fn mitm_substitute_yields_different_code() {
429        // Mallory MITMs: Alice's traffic to Bob is replaced with
430        // Mallory's pubkey, and vice versa. Alice computes ECDH with
431        // Mallory's pub; Bob computes ECDH with Mallory's pub. Their
432        // SAS codes will both differ from each other and from a
433        // legitimate same-pubkey-pair derivation — so OOB comparison
434        // catches the attack.
435        let (tx_id, alice_secret, alice_pub) = new_session();
436        let (_, bob_secret, bob_pub) = new_session();
437        let (_, _mallory_secret, mallory_pub) = new_session();
438
439        let alice_thinks_bob =
440            derive_sas_code(&alice_secret, &mallory_pub, &tx_id, None, None).unwrap();
441        let bob_thinks_alice =
442            derive_sas_code(&bob_secret, &mallory_pub, &tx_id, None, None).unwrap();
443        assert_ne!(alice_thinks_bob, bob_thinks_alice);
444
445        // Sanity: without MITM, both sides agree.
446        let alice_real = derive_sas_code(&alice_secret, &bob_pub, &tx_id, None, None).unwrap();
447        let bob_real = derive_sas_code(&bob_secret, &alice_pub, &tx_id, None, None).unwrap();
448        assert_eq!(alice_real, bob_real);
449    }
450
451    #[test]
452    fn rejects_small_order_ephemeral() {
453        // The X25519 all-zero point is non-contributory (small-order):
454        // ECDH with it yields an all-zero shared secret regardless of our
455        // secret. derive_sas_code must reject it rather than emit a code.
456        let (tx_id, our_secret, _) = new_session();
457        let zero_pub = PublicKey::from([0u8; 32]);
458        assert!(derive_sas_code(&our_secret, &zero_pub, &tx_id, None, None).is_err());
459    }
460
461    // ---- huddle 2.0: post-quantum capability binding ----
462
463    /// A stand-in 1184-byte ML-KEM-768 encapsulation key. The binding hashes
464    /// whatever bytes it is given, so the exact contents are irrelevant here —
465    /// only that both sides feed the *same* bytes.
466    fn fake_ek(fill: u8) -> Vec<u8> {
467        vec![fill; crate::crypto::pqc::MLKEM_EK_LEN]
468    }
469
470    #[test]
471    fn pq_binding_changes_the_code() {
472        // Same shared secret + tx_id, but binding the partner's ML-KEM ek must
473        // yield a different SAS code than the classical (None partner) derivation.
474        let (tx_id, alice_secret, _) = new_session();
475        let (_, _bob_secret, bob_pub) = new_session();
476        let our = fake_ek(0xA4);
477        let ek = fake_ek(0xA5);
478
479        let classical = derive_sas_code(&alice_secret, &bob_pub, &tx_id, None, None).unwrap();
480        let bound =
481            derive_sas_code(&alice_secret, &bob_pub, &tx_id, Some(&our), Some(&ek)).unwrap();
482        assert_ne!(
483            classical, bound,
484            "binding the ML-KEM ek must change the derived SAS code"
485        );
486    }
487
488    #[test]
489    fn both_sides_distinct_eks_agree() {
490        // Two honest PQ-capable peers each bind (our_ek, their_ek) with the keys
491        // in OPPOSITE roles — exactly what the app does. The byte-sorted binding
492        // makes the two derivations produce the SAME code, so verification
493        // succeeds end to end. (This replaces the old both_sides_same_ek_agree,
494        // which only passed because it fed ONE shared ek to both derivations —
495        // a configuration the app never produces.)
496        let (tx_id, alice_secret, alice_pub) = new_session();
497        let (_, bob_secret, bob_pub) = new_session();
498        let ek_a = fake_ek(0x11);
499        let ek_b = fake_ek(0x22);
500
501        // alice = initiator (our = ek_a) binds bob's ek_b
502        let alice =
503            derive_sas_code(&alice_secret, &bob_pub, &tx_id, Some(&ek_a), Some(&ek_b)).unwrap();
504        // bob = responder (our = ek_b) binds alice's ek_a
505        let bob =
506            derive_sas_code(&bob_secret, &alice_pub, &tx_id, Some(&ek_b), Some(&ek_a)).unwrap();
507        assert_eq!(
508            alice, bob,
509            "sorted dual-ek binding must agree across peers in opposite roles"
510        );
511    }
512
513    #[test]
514    fn one_side_bound_other_not_diverges() {
515        // The downgrade-detection invariant: a relay strips the ML-KEM key from
516        // one side's announce, so that side sees the partner as classical
517        // (their_ek = None → classical transcript) while the other still binds
518        // the partner's ek. The codes diverge → OOB comparison catches it.
519        let (tx_id, alice_secret, alice_pub) = new_session();
520        let (_, bob_secret, bob_pub) = new_session();
521        let ek_a = fake_ek(0x77);
522        let ek_b = fake_ek(0x88);
523
524        let alice_bound =
525            derive_sas_code(&alice_secret, &bob_pub, &tx_id, Some(&ek_a), Some(&ek_b)).unwrap();
526        // bob never received alice's ek announce → their_ek = None for bob.
527        let bob_stripped =
528            derive_sas_code(&bob_secret, &alice_pub, &tx_id, Some(&ek_b), None).unwrap();
529        assert_ne!(
530            alice_bound, bob_stripped,
531            "a stripped (classical) side must not match a bound side"
532        );
533    }
534
535    #[test]
536    fn different_ek_yields_different_code() {
537        // Binding two different partner ML-KEM keys produces two different codes,
538        // so the hash genuinely covers the ek bytes (not just a fixed pqbind tag).
539        let (tx_id, secret, _) = new_session();
540        let (_, _b, peer_pub) = new_session();
541        let our = fake_ek(0x00);
542        let a =
543            derive_sas_code(&secret, &peer_pub, &tx_id, Some(&our), Some(&fake_ek(0x01))).unwrap();
544        let b =
545            derive_sas_code(&secret, &peer_pub, &tx_id, Some(&our), Some(&fake_ek(0x02))).unwrap();
546        assert_ne!(a, b, "different bound eks must yield different codes");
547    }
548
549    #[test]
550    fn pqbind_is_order_independent() {
551        // sas_info must be symmetric in (our, their): swapping the roles yields
552        // identical info — the property that makes the two peers agree.
553        let ek_a = fake_ek(0x33);
554        let ek_b = fake_ek(0x44);
555        assert_eq!(
556            sas_info(Some(&ek_a), Some(&ek_b)),
557            sas_info(Some(&ek_b), Some(&ek_a)),
558            "dual-ek binding must be order-independent"
559        );
560    }
561
562    #[test]
563    fn classical_none_path_is_unchanged_golden() {
564        // Lock the classical info to the exact frozen 1.x bytes, so a future
565        // refactor can't silently shift the wire-visible transcript and break
566        // verification against pre-2.0 peers. The gate is on the PARTNER's ek:
567        // a PQ-capable self talking to a classical (None) partner stays classical.
568        assert_eq!(sas_info(None, None), b"huddle-sas-v1".to_vec());
569        assert_eq!(
570            sas_info(Some(&fake_ek(0x01)), None),
571            b"huddle-sas-v1".to_vec(),
572            "PQ-capable self + classical partner must stay on the 1.x transcript"
573        );
574        // With a partner ek, info = domain tag || SHA-256(sorted(our, their)).
575        let ek_a = fake_ek(0x5C);
576        let ek_b = fake_ek(0x6D);
577        let info = sas_info(Some(&ek_a), Some(&ek_b));
578        assert_eq!(info.len(), SAS_INFO_PQBIND.len() + 32);
579        assert!(info.starts_with(SAS_INFO_PQBIND));
580        let mut sorted = [ek_a.as_slice(), ek_b.as_slice()];
581        sorted.sort_unstable();
582        let mut h = Sha256::new();
583        for e in sorted {
584            h.update(e);
585        }
586        assert_eq!(&info[SAS_INFO_PQBIND.len()..], &h.finalize()[..]);
587    }
588
589    #[test]
590    fn pubkey_round_trip() {
591        let (_, _, pub_) = new_session();
592        use base64::engine::general_purpose::STANDARD as B64;
593        use base64::Engine;
594        let encoded = B64.encode(pub_.as_bytes());
595        let decoded = parse_pubkey(&encoded).unwrap();
596        assert_eq!(decoded.as_bytes(), pub_.as_bytes());
597    }
598}