1use hkdf::Hkdf;
30use rand::RngCore;
31use sha2::Sha256;
32use x25519_dalek::{PublicKey, StaticSecret};
33
34use crate::error::{HuddleError, Result};
35
36pub const TX_ID_LEN: usize = 16;
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct SasCode {
43 pub emoji_indices: [u8; 6],
47 pub decimal: String,
50}
51
52impl SasCode {
53 pub fn emoji_string(&self) -> String {
54 self.emoji_indices
55 .iter()
56 .map(|i| SAS_EMOJI[*i as usize].0)
57 .collect::<Vec<_>>()
58 .join(" ")
59 }
60
61 pub fn emoji_labels(&self) -> String {
62 self.emoji_indices
63 .iter()
64 .map(|i| SAS_EMOJI[*i as usize].1)
65 .collect::<Vec<_>>()
66 .join(" / ")
67 }
68}
69
70pub fn new_session() -> ([u8; TX_ID_LEN], StaticSecret, PublicKey) {
74 let mut tx_id = [0u8; TX_ID_LEN];
75 rand::thread_rng().fill_bytes(&mut tx_id);
76 let secret = StaticSecret::random_from_rng(rand::thread_rng());
82 let public = PublicKey::from(&secret);
83 (tx_id, secret, public)
84}
85
86pub fn derive_sas_code(
90 our_secret: &StaticSecret,
91 their_public: &PublicKey,
92 tx_id: &[u8; TX_ID_LEN],
93) -> SasCode {
94 let shared = our_secret.diffie_hellman(their_public);
95 let hk = Hkdf::<Sha256>::new(Some(tx_id), shared.as_bytes());
99 let mut okm = [0u8; 9];
102 hk.expand(b"huddle-sas-v1", &mut okm)
103 .expect("9 bytes is well within HKDF output limit");
104 let mut emoji_indices = [0u8; 6];
105 for i in 0..6 {
106 emoji_indices[i] = okm[i] & 0x3f; }
108 let decimal = format!(
109 "{:06}",
110 (u32::from(okm[6]) << 16 | u32::from(okm[7]) << 8 | u32::from(okm[8])) % 1_000_000
111 );
112 SasCode {
113 emoji_indices,
114 decimal,
115 }
116}
117
118pub const SAS_EMOJI: [(&str, &str); 64] = [
123 ("🐶", "dog"),
124 ("🐱", "cat"),
125 ("🦁", "lion"),
126 ("🐴", "horse"),
127 ("🦄", "unicorn"),
128 ("🐷", "pig"),
129 ("🐘", "elephant"),
130 ("🐰", "rabbit"),
131 ("🐼", "panda"),
132 ("🐔", "rooster"),
133 ("🐧", "penguin"),
134 ("🐢", "turtle"),
135 ("🐟", "fish"),
136 ("🐙", "octopus"),
137 ("🦋", "butterfly"),
138 ("🌷", "flower"),
139 ("🌳", "tree"),
140 ("🌵", "cactus"),
141 ("🍄", "mushroom"),
142 ("🌍", "globe"),
143 ("🌙", "moon"),
144 ("☁️", "cloud"),
145 ("🔥", "fire"),
146 ("🍌", "banana"),
147 ("🍎", "apple"),
148 ("🍓", "strawberry"),
149 ("🌽", "corn"),
150 ("🍕", "pizza"),
151 ("🎂", "cake"),
152 ("❤️", "heart"),
153 ("🙂", "smiley"),
154 ("🤖", "robot"),
155 ("🎩", "hat"),
156 ("👓", "glasses"),
157 ("🔧", "spanner"),
158 ("🎅", "santa"),
159 ("👍", "thumbs up"),
160 ("☂️", "umbrella"),
161 ("⌛", "hourglass"),
162 ("⏰", "clock"),
163 ("🎁", "gift"),
164 ("💡", "lightbulb"),
165 ("📕", "book"),
166 ("✏️", "pencil"),
167 ("📎", "paperclip"),
168 ("✂️", "scissors"),
169 ("🔒", "lock"),
170 ("🔑", "key"),
171 ("🔨", "hammer"),
172 ("☎️", "telephone"),
173 ("🏁", "flag"),
174 ("🚂", "train"),
175 ("🚲", "bicycle"),
176 ("✈️", "plane"),
177 ("🚀", "rocket"),
178 ("🏆", "trophy"),
179 ("⚽", "ball"),
180 ("🎸", "guitar"),
181 ("🎺", "trumpet"),
182 ("🔔", "bell"),
183 ("⚓", "anchor"),
184 ("🎧", "headphones"),
185 ("📁", "folder"),
186 ("📌", "pin"),
187];
188
189pub fn parse_pubkey(b64: &str) -> Result<PublicKey> {
191 use base64::engine::general_purpose::STANDARD as B64;
192 use base64::Engine;
193 let bytes = B64
194 .decode(b64)
195 .map_err(|e| HuddleError::Session(format!("bad x25519 pubkey b64: {e}")))?;
196 if bytes.len() != 32 {
197 return Err(HuddleError::Session(format!(
198 "x25519 pubkey is {} bytes, expected 32",
199 bytes.len()
200 )));
201 }
202 let mut arr = [0u8; 32];
203 arr.copy_from_slice(&bytes);
204 Ok(PublicKey::from(arr))
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn both_sides_derive_same_code() {
213 let (tx_id, alice_secret, alice_pub) = new_session();
214 let (_, bob_secret, bob_pub) = new_session();
215
216 let alice_code = derive_sas_code(&alice_secret, &bob_pub, &tx_id);
217 let bob_code = derive_sas_code(&bob_secret, &alice_pub, &tx_id);
218 assert_eq!(alice_code, bob_code);
219 assert_eq!(alice_code.decimal.len(), 6);
220 assert!(alice_code.decimal.chars().all(|c| c.is_ascii_digit()));
221 for i in alice_code.emoji_indices {
223 assert!((i as usize) < SAS_EMOJI.len());
224 }
225 }
226
227 #[test]
228 fn different_tx_id_yields_different_code() {
229 let (tx_id_a, alice_secret, _) = new_session();
230 let (_, bob_secret, bob_pub) = new_session();
231 let alice_code = derive_sas_code(&alice_secret, &bob_pub, &tx_id_a);
232
233 let mut tx_id_b = tx_id_a;
234 tx_id_b[0] ^= 0xff;
235 let alice_code_b = derive_sas_code(&alice_secret, &bob_pub, &tx_id_b);
236 let _ = bob_secret;
237 assert_ne!(alice_code, alice_code_b);
238 }
239
240 #[test]
241 fn mitm_substitute_yields_different_code() {
242 let (tx_id, alice_secret, alice_pub) = new_session();
249 let (_, bob_secret, bob_pub) = new_session();
250 let (_, _mallory_secret, mallory_pub) = new_session();
251
252 let alice_thinks_bob = derive_sas_code(&alice_secret, &mallory_pub, &tx_id);
253 let bob_thinks_alice = derive_sas_code(&bob_secret, &mallory_pub, &tx_id);
254 assert_ne!(alice_thinks_bob, bob_thinks_alice);
255
256 let alice_real = derive_sas_code(&alice_secret, &bob_pub, &tx_id);
258 let bob_real = derive_sas_code(&bob_secret, &alice_pub, &tx_id);
259 assert_eq!(alice_real, bob_real);
260 }
261
262 #[test]
263 fn pubkey_round_trip() {
264 let (_, _, pub_) = new_session();
265 use base64::engine::general_purpose::STANDARD as B64;
266 use base64::Engine;
267 let encoded = B64.encode(pub_.as_bytes());
268 let decoded = parse_pubkey(&encoded).unwrap();
269 assert_eq!(decoded.as_bytes(), pub_.as_bytes());
270 }
271}