1use chacha20poly1305::{
4 ChaCha20Poly1305, Nonce,
5 aead::{Aead, KeyInit},
6};
7use hkdf::Hkdf;
8use sha2::Sha256;
9use zeroize::{Zeroize, Zeroizing};
10
11use crate::domain_separation::{SAS_INFO, TRANSPORT_INFO};
12use crate::error::ProtocolError;
13
14pub const SAS_EMOJI: [&str; 256] = [
16 "๐ถ", "๐ฑ", "๐ญ", "๐น", "๐ฐ", "๐ฆ", "๐ป", "๐ผ", "๐จ", "๐ฏ", "๐ฆ", "๐ฎ", "๐ท", "๐ธ", "๐ต", "๐",
17 "๐ง", "๐ฆ", "๐ฆ", "๐ฆ
", "๐ฆ", "๐บ", "๐", "๐ด", "๐ฆ", "๐", "๐", "๐ฆ", "๐", "๐", "๐", "๐ชฒ",
18 "๐ข", "๐", "๐ฆ", "๐ฆ", "๐", "๐ฆ", "๐ฆ", "๐ฆ", "๐ ", "๐ก", "๐ฌ", "๐ฆ", "๐ณ", "๐", "๐", "๐",
19 "๐
", "๐ฆ", "๐ฆ", "๐ฆง", "๐", "๐ฆ", "๐ฆ", "๐ช", "๐ฆ", "๐ฆ", "๐ฆฌ", "๐", "๐", "๐", "๐", "๐",
20 "๐", "๐", "๐", "๐ฆ", "๐", "๐ฉ", "๐ฆฎ", "๐", "๐", "๐ฆ", "๐ฆค", "๐ฆ", "๐ฆ", "๐ฆข", "๐ฆฉ", "๐๏ธ",
21 "๐", "๐ฆ", "๐ฆจ", "๐ฆก", "๐ฆซ", "๐ฆฆ", "๐ฆฅ", "๐", "๐", "๐ฟ๏ธ", "๐ฆ", "๐ต", "๐", "๐ฒ", "๐ณ", "๐ด",
22 "๐ชต", "๐ฑ", "๐ฟ", "โ๏ธ", "๐", "๐", "๐ชด", "๐", "๐", "๐", "๐", "๐พ", "๐บ", "๐ป", "๐น", "๐ฅ",
23 "๐ท", "๐ผ", "๐", "๐", "๐ฐ", "๐", "๐", "๐", "๐", "๐", "๐", "๐", "๐", "๐", "๐", "๐",
24 "๐", "๐", "โญ", "๐", "๐ซ", "โจ", "โ๏ธ", "๐ค๏ธ", "โ
", "๐ฅ๏ธ", "๐ฆ๏ธ", "๐ง๏ธ", "โ๏ธ", "๐ฉ๏ธ", "๐จ๏ธ", "โ๏ธ",
25 "โ๏ธ", "โ", "๐ฌ๏ธ", "๐จ", "๐ช๏ธ", "๐ซ๏ธ", "๐", "๐ง", "๐ฆ", "๐ฅ", "๐ฏ", "๐", "๐", "โพ", "๐ฅ", "๐พ",
26 "๐", "๐", "๐ฅ", "๐ฑ", "๐", "๐ธ", "๐", "๐ฅ", "๐ฟ", "โท๏ธ", "๐", "๐ช", "๐๏ธ", "๐คธ", "โน๏ธ", "๐คบ",
27 "๐", "๐ง", "๐", "๐", "๐ฃ", "๐ง", "๐ด", "๐", "๐ฅ", "๐ฅ", "๐ฅ", "๐
", "๐๏ธ", "๐ช", "๐จ", "๐ญ",
28 "๐น", "๐ฅ", "๐ท", "๐บ", "๐ธ", "๐ช", "๐ป", "๐ฌ", "๐ฎ", "๐น๏ธ", "๐ฒ", "๐งฉ", "๐ฎ", "๐ช", "๐งฟ", "๐ฐ",
29 "๐", "โ๏ธ", "๐ธ", "๐", "๐ถ", "โต", "๐ค", "๐ฅ๏ธ", "๐", "๐", "๐", "๐
", "๐", "๐", "๐", "๐",
30 "๐ ", "๐ก", "๐ข", "๐ฃ", "๐ค", "๐ฅ", "๐ฆ", "๐จ", "๐ฉ", "๐ช", "๐ซ", "๐ฌ", "๐ญ", "๐ฏ", "๐ฐ", "๐",
31 "๐ผ", "๐ฝ", "โช", "๐", "๐", "๐", "โฉ๏ธ", "๐", "โฒ", "โบ", "๐", "๐ป", "๐", "๐พ", "๐๏ธ", "๐ ",
32];
33
34const NONCE_LEN: usize = 12;
35const TAG_LEN: usize = 16;
36
37pub fn derive_sas(
75 shared_secret: &[u8; 32],
76 initiator_pub: &[u8],
77 responder_pub: &[u8],
78 session_id: &str,
79 short_code: &str,
80) -> [u8; 10] {
81 let salt = build_salt(initiator_pub, responder_pub);
82 let info = build_info(SAS_INFO, session_id, short_code);
83
84 let hk = Hkdf::<Sha256>::new(Some(&salt), shared_secret);
85 let mut out = [0u8; 10];
86 let _ = hk.expand(&info, &mut out);
88 out
89}
90
91pub fn format_sas_emoji(sas_bytes: &[u8; 10]) -> String {
95 sas_bytes[..6]
96 .iter()
97 .map(|&b| SAS_EMOJI[b as usize])
98 .collect::<Vec<_>>()
99 .join(" ")
100}
101
102pub fn format_sas_numeric(sas_bytes: &[u8; 10]) -> String {
118 const BOUND: u32 = (u32::MAX / 10_000_000) * 10_000_000;
120
121 let mut val = u32::from_be_bytes([sas_bytes[6], sas_bytes[7], sas_bytes[8], sas_bytes[9]]);
123 let mut tries = 0u8;
124 while val >= BOUND && tries < 8 {
125 use sha2::{Digest, Sha256 as Sha256Hasher};
129 let mut h = Sha256Hasher::new();
130 h.update(&sas_bytes[6..10]);
131 h.update([tries]);
132 let digest = h.finalize();
133 val = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
134 tries = tries.saturating_add(1);
135 }
136 let decimal = val % 10_000_000;
137 format!("{:03}-{:04}", decimal / 10_000, decimal % 10_000)
138}
139
140pub struct TransportKey(Zeroizing<[u8; 32]>);
153
154impl zeroize::ZeroizeOnDrop for TransportKey {}
159
160impl auths_crypto::secret::__private::Sealed for TransportKey {}
166impl auths_crypto::Secret for TransportKey {}
167
168impl TransportKey {
169 pub fn new(key: [u8; 32]) -> Self {
170 Self(Zeroizing::new(key))
171 }
172
173 pub fn encrypt(mut self, plaintext: &[u8]) -> Result<Vec<u8>, ProtocolError> {
177 let cipher = ChaCha20Poly1305::new_from_slice(&*self.0)
178 .map_err(|_| ProtocolError::EncryptionFailed("invalid key".into()))?;
179 let mut nonce_bytes = [0u8; NONCE_LEN];
183 {
184 use p256::elliptic_curve::rand_core::{OsRng, RngCore};
185 OsRng.fill_bytes(&mut nonce_bytes);
186 }
187 let nonce = Nonce::from(nonce_bytes);
188 let ciphertext = cipher
189 .encrypt(&nonce, plaintext)
190 .map_err(|_| ProtocolError::EncryptionFailed("encryption failed".into()))?;
191
192 self.0.zeroize();
193
194 let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
195 out.extend_from_slice(&nonce_bytes);
196 out.extend_from_slice(&ciphertext);
197 Ok(out)
198 }
199
200 pub fn as_bytes(&self) -> &[u8; 32] {
202 &self.0
203 }
204}
205
206pub fn decrypt_from_transport(
212 ciphertext: &[u8],
213 transport_key: &[u8; 32],
214) -> Result<Vec<u8>, ProtocolError> {
215 if ciphertext.len() < NONCE_LEN + TAG_LEN {
216 return Err(ProtocolError::DecryptionFailed(
217 "ciphertext too short".into(),
218 ));
219 }
220 let (nonce_bytes, ct) = ciphertext.split_at(NONCE_LEN);
221 let nonce = Nonce::from_slice(nonce_bytes);
222 let cipher = ChaCha20Poly1305::new_from_slice(transport_key)
223 .map_err(|_| ProtocolError::DecryptionFailed("invalid key".into()))?;
224 cipher
225 .decrypt(nonce, ct)
226 .map_err(|_| ProtocolError::DecryptionFailed("decryption failed".into()))
227}
228
229pub fn derive_transport_key(
240 shared_secret: &[u8; 32],
241 initiator_pub: &[u8],
242 responder_pub: &[u8],
243 session_id: &str,
244 short_code: &str,
245) -> TransportKey {
246 let salt = build_salt(initiator_pub, responder_pub);
247 let info = build_info(TRANSPORT_INFO, session_id, short_code);
248
249 let hk = Hkdf::<Sha256>::new(Some(&salt), shared_secret);
250 let mut key = [0u8; 32];
251 let _ = hk.expand(&info, &mut key);
253 TransportKey::new(key)
254}
255
256fn build_salt(initiator_pub: &[u8], responder_pub: &[u8]) -> Vec<u8> {
257 let mut salt = Vec::with_capacity(initiator_pub.len() + responder_pub.len());
258 salt.extend_from_slice(initiator_pub);
259 salt.extend_from_slice(responder_pub);
260 salt
261}
262
263fn build_info(domain: &[u8], session_id: &str, short_code: &str) -> Vec<u8> {
264 let mut info = Vec::with_capacity(domain.len() + session_id.len() + short_code.len());
265 info.extend_from_slice(domain);
266 info.extend_from_slice(session_id.as_bytes());
267 info.extend_from_slice(short_code.as_bytes());
268 info
269}
270
271#[cfg(test)]
272#[allow(clippy::disallowed_methods)]
273mod tests {
274 use super::*;
275 use std::collections::HashSet;
276
277 const TEST_SECRET: [u8; 32] = [0x42; 32];
278 const TEST_INIT_PUB: [u8; 32] = [0x01; 32];
279 const TEST_RESP_PUB: [u8; 32] = [0x02; 32];
280 const TEST_SHORT_CODE: &str = "ABC123";
281 const TEST_SESSION_ID: &str = "test-session-00000000-0000-0000-0000-000000000000";
282 #[test]
283 fn sas_determinism() {
284 let a = derive_sas(
285 &TEST_SECRET,
286 &TEST_INIT_PUB,
287 &TEST_RESP_PUB,
288 TEST_SESSION_ID,
289 TEST_SHORT_CODE,
290 );
291 let b = derive_sas(
292 &TEST_SECRET,
293 &TEST_INIT_PUB,
294 &TEST_RESP_PUB,
295 TEST_SESSION_ID,
296 TEST_SHORT_CODE,
297 );
298 assert_eq!(a, b);
299 }
300
301 #[test]
302 fn sas_divergence_different_secret() {
303 let a = derive_sas(
304 &TEST_SECRET,
305 &TEST_INIT_PUB,
306 &TEST_RESP_PUB,
307 TEST_SESSION_ID,
308 TEST_SHORT_CODE,
309 );
310 let b = derive_sas(
311 &[0xFF; 32],
312 &TEST_INIT_PUB,
313 &TEST_RESP_PUB,
314 TEST_SESSION_ID,
315 TEST_SHORT_CODE,
316 );
317 assert_ne!(a, b);
318 }
319
320 #[test]
321 fn sas_divergence_different_pubkeys() {
322 let a = derive_sas(
323 &TEST_SECRET,
324 &TEST_INIT_PUB,
325 &TEST_RESP_PUB,
326 TEST_SESSION_ID,
327 TEST_SHORT_CODE,
328 );
329 let b = derive_sas(
330 &TEST_SECRET,
331 &[0x03; 32],
332 &TEST_RESP_PUB,
333 TEST_SESSION_ID,
334 TEST_SHORT_CODE,
335 );
336 assert_ne!(a, b);
337 }
338
339 #[test]
340 fn domain_separation() {
341 let sas = derive_sas(
342 &TEST_SECRET,
343 &TEST_INIT_PUB,
344 &TEST_RESP_PUB,
345 TEST_SESSION_ID,
346 TEST_SHORT_CODE,
347 );
348 let tk = derive_transport_key(
349 &TEST_SECRET,
350 &TEST_INIT_PUB,
351 &TEST_RESP_PUB,
352 TEST_SESSION_ID,
353 TEST_SHORT_CODE,
354 );
355 assert_ne!(&sas[..], &tk.as_bytes()[..8]);
356 }
357
358 #[test]
359 fn emoji_format() {
360 let sas = derive_sas(
361 &TEST_SECRET,
362 &TEST_INIT_PUB,
363 &TEST_RESP_PUB,
364 TEST_SESSION_ID,
365 TEST_SHORT_CODE,
366 );
367 let emoji = format_sas_emoji(&sas);
368 let parts: Vec<&str> = emoji.split(" ").collect();
369 assert_eq!(parts.len(), 6);
370 for part in &parts {
371 assert!(SAS_EMOJI.contains(part), "emoji {part} not in wordlist");
372 }
373 }
374
375 #[test]
376 fn numeric_format() {
377 let sas = derive_sas(
378 &TEST_SECRET,
379 &TEST_INIT_PUB,
380 &TEST_RESP_PUB,
381 TEST_SESSION_ID,
382 TEST_SHORT_CODE,
383 );
384 let numeric = format_sas_numeric(&sas);
385 let re = regex_lite::Regex::new(r"^\d{3}-\d{4}$").unwrap();
386 assert!(re.is_match(&numeric), "numeric format wrong: {numeric}");
387 }
388
389 #[test]
390 fn emoji_wordlist_integrity() {
391 assert_eq!(SAS_EMOJI.len(), 256);
392 let set: HashSet<&str> = SAS_EMOJI.iter().copied().collect();
393 assert_eq!(set.len(), 256, "duplicate emoji in wordlist");
394 }
395
396 #[test]
397 fn transport_encryption_roundtrip() {
398 let tk = derive_transport_key(
399 &TEST_SECRET,
400 &TEST_INIT_PUB,
401 &TEST_RESP_PUB,
402 TEST_SESSION_ID,
403 TEST_SHORT_CODE,
404 );
405 let key_bytes = *tk.as_bytes();
406 let plaintext = b"test attestation payload";
407 let ciphertext = tk.encrypt(plaintext).unwrap();
408 assert_eq!(ciphertext.len(), NONCE_LEN + plaintext.len() + TAG_LEN);
409 let decrypted = decrypt_from_transport(&ciphertext, &key_bytes).unwrap();
410 assert_eq!(decrypted, plaintext);
411 }
412
413 #[test]
414 fn transport_encryption_wrong_key() {
415 let tk = derive_transport_key(
416 &TEST_SECRET,
417 &TEST_INIT_PUB,
418 &TEST_RESP_PUB,
419 TEST_SESSION_ID,
420 TEST_SHORT_CODE,
421 );
422 let ciphertext = tk.encrypt(b"secret").unwrap();
423 let result = decrypt_from_transport(&ciphertext, &[0xFF; 32]);
424 assert!(matches!(result, Err(ProtocolError::DecryptionFailed(_))));
425 }
426
427 #[test]
428 fn sas_is_deterministic_and_ten_bytes() {
429 let a = derive_sas(
435 &TEST_SECRET,
436 &TEST_INIT_PUB,
437 &TEST_RESP_PUB,
438 TEST_SESSION_ID,
439 TEST_SHORT_CODE,
440 );
441 let b = derive_sas(
442 &TEST_SECRET,
443 &TEST_INIT_PUB,
444 &TEST_RESP_PUB,
445 TEST_SESSION_ID,
446 TEST_SHORT_CODE,
447 );
448 assert_eq!(a, b, "derive_sas must be deterministic");
449 assert_eq!(a.len(), 10);
450 assert_ne!(a, [0u8; 10], "SAS must not be all zeros");
451 let numeric = format_sas_numeric(&a);
452 assert_eq!(numeric.len(), "XXX-XXXX".len());
453 let emoji = format_sas_emoji(&a);
454 assert!(!emoji.is_empty());
455 }
456
457 #[test]
458 fn format_sas_numeric_is_unbiased_over_many_draws() {
459 let mut rng = p256::elliptic_curve::rand_core::OsRng;
465 let mut counts = [0usize; 10];
466 const N: usize = 10_000;
467 for _ in 0..N {
468 let mut sas = [0u8; 10];
469 rand::RngCore::fill_bytes(&mut rng, &mut sas);
470 let s = format_sas_numeric(&sas);
471 let first_digit = s.chars().next().unwrap().to_digit(10).unwrap() as usize;
472 counts[first_digit] += 1;
473 }
474 for (d, &c) in counts.iter().enumerate() {
475 let frac = c as f64 / N as f64;
476 assert!(
477 (0.08..=0.12).contains(&frac),
478 "digit {d} freq {frac:.4} outside [0.08, 0.12] (count {c} of {N})"
479 );
480 }
481 }
482
483 #[test]
484 fn mitm_simulation_produces_different_sas() {
485 let real_shared = [0x42u8; 32];
489 let attacker_shared_a = [0xAA; 32];
490 let attacker_shared_b = [0xBB; 32];
491
492 let sas_real = derive_sas(
493 &real_shared,
494 &TEST_INIT_PUB,
495 &TEST_RESP_PUB,
496 TEST_SESSION_ID,
497 TEST_SHORT_CODE,
498 );
499 let sas_mitm_a = derive_sas(
500 &attacker_shared_a,
501 &TEST_INIT_PUB,
502 &[0x03; 32],
503 TEST_SESSION_ID,
504 TEST_SHORT_CODE,
505 );
506 let sas_mitm_b = derive_sas(
507 &attacker_shared_b,
508 &[0x04; 32],
509 &TEST_RESP_PUB,
510 TEST_SESSION_ID,
511 TEST_SHORT_CODE,
512 );
513
514 assert_ne!(sas_real, sas_mitm_a);
515 assert_ne!(sas_real, sas_mitm_b);
516 assert_ne!(sas_mitm_a, sas_mitm_b);
517 }
518
519 #[test]
520 fn transport_key_decrypt_short_ciphertext() {
521 let result = decrypt_from_transport(&[0u8; 10], &[0u8; 32]);
522 assert!(matches!(result, Err(ProtocolError::DecryptionFailed(_))));
523 }
524
525 #[test]
526 fn different_session_id_produces_different_sas() {
527 let sas_a = derive_sas(
528 &TEST_SECRET,
529 &TEST_INIT_PUB,
530 &TEST_RESP_PUB,
531 "session-aaaa",
532 TEST_SHORT_CODE,
533 );
534 let sas_b = derive_sas(
535 &TEST_SECRET,
536 &TEST_INIT_PUB,
537 &TEST_RESP_PUB,
538 "session-bbbb",
539 TEST_SHORT_CODE,
540 );
541 assert_ne!(
542 sas_a, sas_b,
543 "same short_code but different session_id must produce different SAS"
544 );
545 }
546
547 #[test]
555 fn transport_key_zeroizes_on_drop() {
556 let tk = TransportKey::new([0xA5; 32]);
557 let ptr: *const u8 = tk.as_bytes().as_ptr();
558 drop(tk);
559 let _ = ptr; let fresh = TransportKey::new([0xA5; 32]);
572 assert_eq!(fresh.as_bytes(), &[0xA5; 32]);
573 }
577}