use hkdf::Hkdf;
use rand::RngCore;
use sha2::Sha256;
use x25519_dalek::{PublicKey, StaticSecret};
use crate::error::{HuddleError, Result};
pub const TX_ID_LEN: usize = 16;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SasCode {
pub emoji_indices: [u8; 7],
pub decimal: String,
}
impl SasCode {
pub fn emoji_string(&self) -> String {
self.emoji_indices
.iter()
.map(|i| SAS_EMOJI[*i as usize].0)
.collect::<Vec<_>>()
.join(" ")
}
pub fn emoji_labels(&self) -> String {
self.emoji_indices
.iter()
.map(|i| SAS_EMOJI[*i as usize].1)
.collect::<Vec<_>>()
.join(" / ")
}
}
pub fn new_session() -> ([u8; TX_ID_LEN], StaticSecret, PublicKey) {
let mut tx_id = [0u8; TX_ID_LEN];
rand::thread_rng().fill_bytes(&mut tx_id);
let secret = StaticSecret::random_from_rng(rand::thread_rng());
let public = PublicKey::from(&secret);
(tx_id, secret, public)
}
pub fn derive_sas_code(
our_secret: &StaticSecret,
their_public: &PublicKey,
tx_id: &[u8; TX_ID_LEN],
) -> SasCode {
let shared = our_secret.diffie_hellman(their_public);
let hk = Hkdf::<Sha256>::new(Some(tx_id), shared.as_bytes());
let mut okm = [0u8; 11];
hk.expand(b"huddle-sas-v1", &mut okm)
.expect("11 bytes is well within HKDF output limit");
let b = &okm[..6];
let mut raw_emoji = [0u8; 7];
raw_emoji[0] = b[0] >> 2;
raw_emoji[1] = ((b[0] & 0x03) << 4) | (b[1] >> 4);
raw_emoji[2] = ((b[1] & 0x0f) << 2) | (b[2] >> 6);
raw_emoji[3] = b[2] & 0x3f;
raw_emoji[4] = b[3] >> 2;
raw_emoji[5] = ((b[3] & 0x03) << 4) | (b[4] >> 4);
raw_emoji[6] = ((b[4] & 0x0f) << 2) | (b[5] >> 6);
let emoji_indices = derive_emoji_indices_rejection(&hk, raw_emoji);
let d = &okm[6..11];
let chunk0 = ((u32::from(d[0]) << 5) | (u32::from(d[1]) >> 3)) & 0x1fff;
let chunk1 = ((u32::from(d[1] & 0x07) << 10)
| (u32::from(d[2]) << 2)
| (u32::from(d[3]) >> 6))
& 0x1fff;
let chunk2 = ((u32::from(d[3] & 0x3f) << 7) | (u32::from(d[4]) >> 1)) & 0x1fff;
let decimal = format!("{}-{}-{}", chunk0 + 1000, chunk1 + 1000, chunk2 + 1000);
SasCode {
emoji_indices,
decimal,
}
}
pub const SAS_EMOJI: [(&str, &str); 49] = [
("🐶", "dog"),
("🐱", "cat"),
("🦁", "lion"),
("🐎", "horse"),
("🦄", "unicorn"),
("🐷", "pig"),
("🐘", "elephant"),
("🐰", "rabbit"),
("🐼", "panda"),
("🐓", "rooster"),
("🐧", "penguin"),
("🐢", "turtle"),
("🐟", "fish"),
("🐙", "octopus"),
("🦋", "butterfly"),
("🌷", "flower"),
("🌳", "tree"),
("🌵", "cactus"),
("🍄", "mushroom"),
("🌏", "globe"),
("🌙", "moon"),
("☁️", "cloud"),
("🔥", "fire"),
("🍌", "banana"),
("🍎", "apple"),
("🍓", "strawberry"),
("🌽", "corn"),
("🍕", "pizza"),
("🎂", "cake"),
("❤️", "heart"),
("🙂", "smiley"),
("🤖", "robot"),
("🎩", "hat"),
("👓", "glasses"),
("🔧", "spanner"),
("🎅", "santa"),
("👍", "thumbs up"),
("☂️", "umbrella"),
("⌛", "hourglass"),
("⏰", "clock"),
("🎁", "gift"),
("💡", "light bulb"),
("📕", "book"),
("✏️", "pencil"),
("📎", "paperclip"),
("✂️", "scissors"),
("🔒", "lock"),
("🔑", "key"),
("🔨", "hammer"),
];
fn derive_emoji_indices_rejection(
hk: &Hkdf<Sha256>,
initial: [u8; 7],
) -> [u8; 7] {
let mut out = [0u8; 7];
let mut accepted = 0usize;
for &v in &initial {
if v < 49 {
out[accepted] = v;
accepted += 1;
if accepted == 7 {
return out;
}
}
}
let mut counter: u32 = 0;
while accepted < 7 {
let info = {
let mut buf = [0u8; 24];
buf[..16].copy_from_slice(b"huddle-sas-v1-rs");
buf[16..20].copy_from_slice(&counter.to_be_bytes());
buf
};
let mut block = [0u8; 32];
if hk.expand(&info, &mut block).is_err() {
for v in &mut initial.iter().copied() {
if accepted < 7 {
out[accepted] = v % 49;
accepted += 1;
}
}
break;
}
for &byte in block.iter() {
let candidate = byte & 0x3f;
if candidate < 49 {
out[accepted] = candidate;
accepted += 1;
if accepted == 7 {
return out;
}
}
}
counter += 1;
}
out
}
pub fn parse_pubkey(b64: &str) -> Result<PublicKey> {
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
let bytes = B64
.decode(b64)
.map_err(|e| HuddleError::Session(format!("bad x25519 pubkey b64: {e}")))?;
if bytes.len() != 32 {
return Err(HuddleError::Session(format!(
"x25519 pubkey is {} bytes, expected 32",
bytes.len()
)));
}
let mut arr = [0u8; 32];
arr.copy_from_slice(&bytes);
Ok(PublicKey::from(arr))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn both_sides_derive_same_code() {
let (tx_id, alice_secret, alice_pub) = new_session();
let (_, bob_secret, bob_pub) = new_session();
let alice_code = derive_sas_code(&alice_secret, &bob_pub, &tx_id);
let bob_code = derive_sas_code(&bob_secret, &alice_pub, &tx_id);
assert_eq!(alice_code, bob_code);
let parts: Vec<&str> = alice_code.decimal.split('-').collect();
assert_eq!(parts.len(), 3);
for p in parts {
assert_eq!(p.len(), 4);
let n: u32 = p.parse().unwrap();
assert!((1000..=9191).contains(&n));
}
for i in alice_code.emoji_indices {
assert!((i as usize) < SAS_EMOJI.len());
}
}
#[test]
fn different_tx_id_yields_different_code() {
let (tx_id_a, alice_secret, _) = new_session();
let (_, bob_secret, bob_pub) = new_session();
let alice_code = derive_sas_code(&alice_secret, &bob_pub, &tx_id_a);
let mut tx_id_b = tx_id_a;
tx_id_b[0] ^= 0xff;
let alice_code_b = derive_sas_code(&alice_secret, &bob_pub, &tx_id_b);
let _ = bob_secret;
assert_ne!(alice_code, alice_code_b);
}
#[test]
fn mitm_substitute_yields_different_code() {
let (tx_id, alice_secret, alice_pub) = new_session();
let (_, bob_secret, bob_pub) = new_session();
let (_, _mallory_secret, mallory_pub) = new_session();
let alice_thinks_bob = derive_sas_code(&alice_secret, &mallory_pub, &tx_id);
let bob_thinks_alice = derive_sas_code(&bob_secret, &mallory_pub, &tx_id);
assert_ne!(alice_thinks_bob, bob_thinks_alice);
let alice_real = derive_sas_code(&alice_secret, &bob_pub, &tx_id);
let bob_real = derive_sas_code(&bob_secret, &alice_pub, &tx_id);
assert_eq!(alice_real, bob_real);
}
#[test]
fn pubkey_round_trip() {
let (_, _, pub_) = new_session();
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
let encoded = B64.encode(pub_.as_bytes());
let decoded = parse_pubkey(&encoded).unwrap();
assert_eq!(decoded.as_bytes(), pub_.as_bytes());
}
}