use chacha20poly1305::{
ChaCha20Poly1305, Nonce,
aead::{Aead, KeyInit},
};
use hkdf::Hkdf;
use sha2::Sha256;
use zeroize::{Zeroize, Zeroizing};
use crate::domain_separation::{SAS_INFO, TRANSPORT_INFO};
use crate::error::ProtocolError;
pub const SAS_EMOJI: [&str; 256] = [
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷", "🐸", "🐵", "🐔",
"🐧", "🐦", "🦆", "🦅", "🦉", "🐺", "🐗", "🐴", "🦄", "🐝", "🐛", "🦋", "🐌", "🐞", "🐜", "🪲",
"🐢", "🐍", "🦎", "🦂", "🐙", "🦑", "🦐", "🦞", "🐠", "🐡", "🐬", "🦈", "🐳", "🐋", "🐊", "🐆",
"🐅", "🦓", "🦍", "🦧", "🐘", "🦛", "🦏", "🐪", "🦒", "🦘", "🦬", "🐃", "🐂", "🐄", "🐎", "🐖",
"🐏", "🐑", "🐐", "🦌", "🐕", "🐩", "🦮", "🐈", "🐓", "🦃", "🦤", "🦚", "🦜", "🦢", "🦩", "🕊️",
"🐇", "🦝", "🦨", "🦡", "🦫", "🦦", "🦥", "🐁", "🐀", "🐿️", "🦔", "🌵", "🎄", "🌲", "🌳", "🌴",
"🪵", "🌱", "🌿", "☘️", "🍀", "🎍", "🪴", "🎋", "🍃", "🍂", "🍁", "🌾", "🌺", "🌻", "🌹", "🥀",
"🌷", "🌼", "💐", "🍄", "🌰", "🎃", "🌎", "🌍", "🌏", "🌕", "🌖", "🌗", "🌘", "🌑", "🌒", "🌓",
"🌔", "🌙", "⭐", "🌟", "💫", "✨", "☀️", "🌤️", "⛅", "🌥️", "🌦️", "🌧️", "⛈️", "🌩️", "🌨️", "❄️",
"☃️", "⛄", "🌬️", "💨", "🌪️", "🌫️", "🌊", "💧", "💦", "🔥", "🎯", "🏀", "🏈", "⚾", "🥎", "🎾",
"🏐", "🏉", "🥏", "🎱", "🏓", "🏸", "🏒", "🥊", "🎿", "⛷️", "🏂", "🪂", "🏋️", "🤸", "⛹️", "🤺",
"🏇", "🧘", "🏄", "🏊", "🚣", "🧗", "🚴", "🏆", "🥇", "🥈", "🥉", "🏅", "🎖️", "🎪", "🎨", "🎭",
"🎹", "🥁", "🎷", "🎺", "🎸", "🪕", "🎻", "🎬", "🎮", "🕹️", "🎲", "🧩", "🔮", "🪄", "🧿", "🎰",
"🚀", "✈️", "🛸", "🚁", "🛶", "⛵", "🚤", "🛥️", "🚂", "🚃", "🚄", "🚅", "🚆", "🚇", "🚈", "🚊",
"🏠", "🏡", "🏢", "🏣", "🏤", "🏥", "🏦", "🏨", "🏩", "🏪", "🏫", "🏬", "🏭", "🏯", "🏰", "💒",
"🗼", "🗽", "⛪", "🕌", "🛕", "🕍", "⛩️", "🕋", "⛲", "⛺", "🌁", "🗻", "🌋", "🗾", "🏕️", "🎠",
];
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
pub fn derive_sas(
shared_secret: &[u8; 32],
initiator_pub: &[u8],
responder_pub: &[u8],
session_id: &str,
short_code: &str,
) -> [u8; 10] {
let salt = build_salt(initiator_pub, responder_pub);
let info = build_info(SAS_INFO, session_id, short_code);
let hk = Hkdf::<Sha256>::new(Some(&salt), shared_secret);
let mut out = [0u8; 10];
let _ = hk.expand(&info, &mut out);
out
}
pub fn format_sas_emoji(sas_bytes: &[u8; 10]) -> String {
sas_bytes[..6]
.iter()
.map(|&b| SAS_EMOJI[b as usize])
.collect::<Vec<_>>()
.join(" ")
}
pub fn format_sas_numeric(sas_bytes: &[u8; 10]) -> String {
const BOUND: u32 = (u32::MAX / 10_000_000) * 10_000_000;
let mut val = u32::from_be_bytes([sas_bytes[6], sas_bytes[7], sas_bytes[8], sas_bytes[9]]);
let mut tries = 0u8;
while val >= BOUND && tries < 8 {
use sha2::{Digest, Sha256 as Sha256Hasher};
let mut h = Sha256Hasher::new();
h.update(&sas_bytes[6..10]);
h.update([tries]);
let digest = h.finalize();
val = u32::from_be_bytes([digest[0], digest[1], digest[2], digest[3]]);
tries = tries.saturating_add(1);
}
let decimal = val % 10_000_000;
format!("{:03}-{:04}", decimal / 10_000, decimal % 10_000)
}
pub struct TransportKey(Zeroizing<[u8; 32]>);
impl zeroize::ZeroizeOnDrop for TransportKey {}
impl auths_crypto::secret::__private::Sealed for TransportKey {}
impl auths_crypto::Secret for TransportKey {}
impl TransportKey {
pub fn new(key: [u8; 32]) -> Self {
Self(Zeroizing::new(key))
}
pub fn encrypt(mut self, plaintext: &[u8]) -> Result<Vec<u8>, ProtocolError> {
let cipher = ChaCha20Poly1305::new_from_slice(&*self.0)
.map_err(|_| ProtocolError::EncryptionFailed("invalid key".into()))?;
let mut nonce_bytes = [0u8; NONCE_LEN];
{
use p256::elliptic_curve::rand_core::{OsRng, RngCore};
OsRng.fill_bytes(&mut nonce_bytes);
}
let nonce = Nonce::from(nonce_bytes);
let ciphertext = cipher
.encrypt(&nonce, plaintext)
.map_err(|_| ProtocolError::EncryptionFailed("encryption failed".into()))?;
self.0.zeroize();
let mut out = Vec::with_capacity(NONCE_LEN + ciphertext.len());
out.extend_from_slice(&nonce_bytes);
out.extend_from_slice(&ciphertext);
Ok(out)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
pub fn decrypt_from_transport(
ciphertext: &[u8],
transport_key: &[u8; 32],
) -> Result<Vec<u8>, ProtocolError> {
if ciphertext.len() < NONCE_LEN + TAG_LEN {
return Err(ProtocolError::DecryptionFailed(
"ciphertext too short".into(),
));
}
let (nonce_bytes, ct) = ciphertext.split_at(NONCE_LEN);
let nonce = Nonce::from_slice(nonce_bytes);
let cipher = ChaCha20Poly1305::new_from_slice(transport_key)
.map_err(|_| ProtocolError::DecryptionFailed("invalid key".into()))?;
cipher
.decrypt(nonce, ct)
.map_err(|_| ProtocolError::DecryptionFailed("decryption failed".into()))
}
pub fn derive_transport_key(
shared_secret: &[u8; 32],
initiator_pub: &[u8],
responder_pub: &[u8],
session_id: &str,
short_code: &str,
) -> TransportKey {
let salt = build_salt(initiator_pub, responder_pub);
let info = build_info(TRANSPORT_INFO, session_id, short_code);
let hk = Hkdf::<Sha256>::new(Some(&salt), shared_secret);
let mut key = [0u8; 32];
let _ = hk.expand(&info, &mut key);
TransportKey::new(key)
}
fn build_salt(initiator_pub: &[u8], responder_pub: &[u8]) -> Vec<u8> {
let mut salt = Vec::with_capacity(initiator_pub.len() + responder_pub.len());
salt.extend_from_slice(initiator_pub);
salt.extend_from_slice(responder_pub);
salt
}
fn build_info(domain: &[u8], session_id: &str, short_code: &str) -> Vec<u8> {
let mut info = Vec::with_capacity(domain.len() + session_id.len() + short_code.len());
info.extend_from_slice(domain);
info.extend_from_slice(session_id.as_bytes());
info.extend_from_slice(short_code.as_bytes());
info
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
use std::collections::HashSet;
const TEST_SECRET: [u8; 32] = [0x42; 32];
const TEST_INIT_PUB: [u8; 32] = [0x01; 32];
const TEST_RESP_PUB: [u8; 32] = [0x02; 32];
const TEST_SHORT_CODE: &str = "ABC123";
const TEST_SESSION_ID: &str = "test-session-00000000-0000-0000-0000-000000000000";
#[test]
fn sas_determinism() {
let a = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let b = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
assert_eq!(a, b);
}
#[test]
fn sas_divergence_different_secret() {
let a = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let b = derive_sas(
&[0xFF; 32],
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
assert_ne!(a, b);
}
#[test]
fn sas_divergence_different_pubkeys() {
let a = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let b = derive_sas(
&TEST_SECRET,
&[0x03; 32],
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
assert_ne!(a, b);
}
#[test]
fn domain_separation() {
let sas = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let tk = derive_transport_key(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
assert_ne!(&sas[..], &tk.as_bytes()[..8]);
}
#[test]
fn emoji_format() {
let sas = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let emoji = format_sas_emoji(&sas);
let parts: Vec<&str> = emoji.split(" ").collect();
assert_eq!(parts.len(), 6);
for part in &parts {
assert!(SAS_EMOJI.contains(part), "emoji {part} not in wordlist");
}
}
#[test]
fn numeric_format() {
let sas = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let numeric = format_sas_numeric(&sas);
let re = regex_lite::Regex::new(r"^\d{3}-\d{4}$").unwrap();
assert!(re.is_match(&numeric), "numeric format wrong: {numeric}");
}
#[test]
fn emoji_wordlist_integrity() {
assert_eq!(SAS_EMOJI.len(), 256);
let set: HashSet<&str> = SAS_EMOJI.iter().copied().collect();
assert_eq!(set.len(), 256, "duplicate emoji in wordlist");
}
#[test]
fn transport_encryption_roundtrip() {
let tk = derive_transport_key(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let key_bytes = *tk.as_bytes();
let plaintext = b"test attestation payload";
let ciphertext = tk.encrypt(plaintext).unwrap();
assert_eq!(ciphertext.len(), NONCE_LEN + plaintext.len() + TAG_LEN);
let decrypted = decrypt_from_transport(&ciphertext, &key_bytes).unwrap();
assert_eq!(decrypted, plaintext);
}
#[test]
fn transport_encryption_wrong_key() {
let tk = derive_transport_key(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let ciphertext = tk.encrypt(b"secret").unwrap();
let result = decrypt_from_transport(&ciphertext, &[0xFF; 32]);
assert!(matches!(result, Err(ProtocolError::DecryptionFailed(_))));
}
#[test]
fn sas_is_deterministic_and_ten_bytes() {
let a = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let b = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
assert_eq!(a, b, "derive_sas must be deterministic");
assert_eq!(a.len(), 10);
assert_ne!(a, [0u8; 10], "SAS must not be all zeros");
let numeric = format_sas_numeric(&a);
assert_eq!(numeric.len(), "XXX-XXXX".len());
let emoji = format_sas_emoji(&a);
assert!(!emoji.is_empty());
}
#[test]
fn format_sas_numeric_is_unbiased_over_many_draws() {
let mut rng = p256::elliptic_curve::rand_core::OsRng;
let mut counts = [0usize; 10];
const N: usize = 10_000;
for _ in 0..N {
let mut sas = [0u8; 10];
rand::RngCore::fill_bytes(&mut rng, &mut sas);
let s = format_sas_numeric(&sas);
let first_digit = s.chars().next().unwrap().to_digit(10).unwrap() as usize;
counts[first_digit] += 1;
}
for (d, &c) in counts.iter().enumerate() {
let frac = c as f64 / N as f64;
assert!(
(0.08..=0.12).contains(&frac),
"digit {d} freq {frac:.4} outside [0.08, 0.12] (count {c} of {N})"
);
}
}
#[test]
fn mitm_simulation_produces_different_sas() {
let real_shared = [0x42u8; 32];
let attacker_shared_a = [0xAA; 32];
let attacker_shared_b = [0xBB; 32];
let sas_real = derive_sas(
&real_shared,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let sas_mitm_a = derive_sas(
&attacker_shared_a,
&TEST_INIT_PUB,
&[0x03; 32],
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
let sas_mitm_b = derive_sas(
&attacker_shared_b,
&[0x04; 32],
&TEST_RESP_PUB,
TEST_SESSION_ID,
TEST_SHORT_CODE,
);
assert_ne!(sas_real, sas_mitm_a);
assert_ne!(sas_real, sas_mitm_b);
assert_ne!(sas_mitm_a, sas_mitm_b);
}
#[test]
fn transport_key_decrypt_short_ciphertext() {
let result = decrypt_from_transport(&[0u8; 10], &[0u8; 32]);
assert!(matches!(result, Err(ProtocolError::DecryptionFailed(_))));
}
#[test]
fn different_session_id_produces_different_sas() {
let sas_a = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
"session-aaaa",
TEST_SHORT_CODE,
);
let sas_b = derive_sas(
&TEST_SECRET,
&TEST_INIT_PUB,
&TEST_RESP_PUB,
"session-bbbb",
TEST_SHORT_CODE,
);
assert_ne!(
sas_a, sas_b,
"same short_code but different session_id must produce different SAS"
);
}
#[test]
fn transport_key_zeroizes_on_drop() {
let tk = TransportKey::new([0xA5; 32]);
let ptr: *const u8 = tk.as_bytes().as_ptr();
drop(tk);
let _ = ptr; let fresh = TransportKey::new([0xA5; 32]);
assert_eq!(fresh.as_bytes(), &[0xA5; 32]);
}
}