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}