1use hkdf::Hkdf;
40use rand::RngCore;
41use sha2::Sha256;
42use x25519_dalek::{PublicKey, StaticSecret};
43
44use crate::error::{HuddleError, Result};
45
46pub const TX_ID_LEN: usize = 16;
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct SasCode {
53 pub emoji_indices: [u8; 7],
57 pub decimal: String,
60}
61
62impl SasCode {
63 pub fn emoji_string(&self) -> String {
64 self.emoji_indices
65 .iter()
66 .map(|i| SAS_EMOJI[*i as usize].0)
67 .collect::<Vec<_>>()
68 .join(" ")
69 }
70
71 pub fn emoji_labels(&self) -> String {
72 self.emoji_indices
73 .iter()
74 .map(|i| SAS_EMOJI[*i as usize].1)
75 .collect::<Vec<_>>()
76 .join(" / ")
77 }
78}
79
80pub fn new_session() -> ([u8; TX_ID_LEN], StaticSecret, PublicKey) {
84 let mut tx_id = [0u8; TX_ID_LEN];
85 rand::thread_rng().fill_bytes(&mut tx_id);
86 let secret = StaticSecret::random_from_rng(rand::thread_rng());
92 let public = PublicKey::from(&secret);
93 (tx_id, secret, public)
94}
95
96pub fn derive_sas_code(
106 our_secret: &StaticSecret,
107 their_public: &PublicKey,
108 tx_id: &[u8; TX_ID_LEN],
109) -> SasCode {
110 let shared = our_secret.diffie_hellman(their_public);
111 let hk = Hkdf::<Sha256>::new(Some(tx_id), shared.as_bytes());
115 let mut okm = [0u8; 11];
116 hk.expand(b"huddle-sas-v1", &mut okm)
117 .expect("11 bytes is well within HKDF output limit");
118
119 let b = &okm[..6];
122 let mut raw_emoji = [0u8; 7];
123 raw_emoji[0] = b[0] >> 2;
124 raw_emoji[1] = ((b[0] & 0x03) << 4) | (b[1] >> 4);
125 raw_emoji[2] = ((b[1] & 0x0f) << 2) | (b[2] >> 6);
126 raw_emoji[3] = b[2] & 0x3f;
127 raw_emoji[4] = b[3] >> 2;
128 raw_emoji[5] = ((b[3] & 0x03) << 4) | (b[4] >> 4);
129 raw_emoji[6] = ((b[4] & 0x0f) << 2) | (b[5] >> 6);
130 let mut emoji_indices = [0u8; 7];
131 for i in 0..7 {
132 emoji_indices[i] = raw_emoji[i] % (SAS_EMOJI.len() as u8);
135 }
136
137 let d = &okm[6..11];
140 let chunk0 = ((u32::from(d[0]) << 5) | (u32::from(d[1]) >> 3)) & 0x1fff;
141 let chunk1 = ((u32::from(d[1] & 0x07) << 10)
142 | (u32::from(d[2]) << 2)
143 | (u32::from(d[3]) >> 6))
144 & 0x1fff;
145 let chunk2 = ((u32::from(d[3] & 0x3f) << 7) | (u32::from(d[4]) >> 1)) & 0x1fff;
146 let decimal = format!("{}-{}-{}", chunk0 + 1000, chunk1 + 1000, chunk2 + 1000);
147
148 SasCode {
149 emoji_indices,
150 decimal,
151 }
152}
153
154pub const SAS_EMOJI: [(&str, &str); 49] = [
157 ("🐶", "dog"),
158 ("🐱", "cat"),
159 ("🦁", "lion"),
160 ("🐎", "horse"),
161 ("🦄", "unicorn"),
162 ("🐷", "pig"),
163 ("🐘", "elephant"),
164 ("🐰", "rabbit"),
165 ("🐼", "panda"),
166 ("🐓", "rooster"),
167 ("🐧", "penguin"),
168 ("🐢", "turtle"),
169 ("🐟", "fish"),
170 ("🐙", "octopus"),
171 ("🦋", "butterfly"),
172 ("🌷", "flower"),
173 ("🌳", "tree"),
174 ("🌵", "cactus"),
175 ("🍄", "mushroom"),
176 ("🌏", "globe"),
177 ("🌙", "moon"),
178 ("☁️", "cloud"),
179 ("🔥", "fire"),
180 ("🍌", "banana"),
181 ("🍎", "apple"),
182 ("🍓", "strawberry"),
183 ("🌽", "corn"),
184 ("🍕", "pizza"),
185 ("🎂", "cake"),
186 ("❤️", "heart"),
187 ("🙂", "smiley"),
188 ("🤖", "robot"),
189 ("🎩", "hat"),
190 ("👓", "glasses"),
191 ("🔧", "spanner"),
192 ("🎅", "santa"),
193 ("👍", "thumbs up"),
194 ("☂️", "umbrella"),
195 ("⌛", "hourglass"),
196 ("⏰", "clock"),
197 ("🎁", "gift"),
198 ("💡", "light bulb"),
199 ("📕", "book"),
200 ("✏️", "pencil"),
201 ("📎", "paperclip"),
202 ("✂️", "scissors"),
203 ("🔒", "lock"),
204 ("🔑", "key"),
205 ("🔨", "hammer"),
206];
207
208pub fn parse_pubkey(b64: &str) -> Result<PublicKey> {
210 use base64::engine::general_purpose::STANDARD as B64;
211 use base64::Engine;
212 let bytes = B64
213 .decode(b64)
214 .map_err(|e| HuddleError::Session(format!("bad x25519 pubkey b64: {e}")))?;
215 if bytes.len() != 32 {
216 return Err(HuddleError::Session(format!(
217 "x25519 pubkey is {} bytes, expected 32",
218 bytes.len()
219 )));
220 }
221 let mut arr = [0u8; 32];
222 arr.copy_from_slice(&bytes);
223 Ok(PublicKey::from(arr))
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn both_sides_derive_same_code() {
232 let (tx_id, alice_secret, alice_pub) = new_session();
233 let (_, bob_secret, bob_pub) = new_session();
234
235 let alice_code = derive_sas_code(&alice_secret, &bob_pub, &tx_id);
236 let bob_code = derive_sas_code(&bob_secret, &alice_pub, &tx_id);
237 assert_eq!(alice_code, bob_code);
238 let parts: Vec<&str> = alice_code.decimal.split('-').collect();
241 assert_eq!(parts.len(), 3);
242 for p in parts {
243 assert_eq!(p.len(), 4);
244 let n: u32 = p.parse().unwrap();
245 assert!((1000..=9191).contains(&n));
246 }
247 for i in alice_code.emoji_indices {
249 assert!((i as usize) < SAS_EMOJI.len());
250 }
251 }
252
253 #[test]
254 fn different_tx_id_yields_different_code() {
255 let (tx_id_a, alice_secret, _) = new_session();
256 let (_, bob_secret, bob_pub) = new_session();
257 let alice_code = derive_sas_code(&alice_secret, &bob_pub, &tx_id_a);
258
259 let mut tx_id_b = tx_id_a;
260 tx_id_b[0] ^= 0xff;
261 let alice_code_b = derive_sas_code(&alice_secret, &bob_pub, &tx_id_b);
262 let _ = bob_secret;
263 assert_ne!(alice_code, alice_code_b);
264 }
265
266 #[test]
267 fn mitm_substitute_yields_different_code() {
268 let (tx_id, alice_secret, alice_pub) = new_session();
275 let (_, bob_secret, bob_pub) = new_session();
276 let (_, _mallory_secret, mallory_pub) = new_session();
277
278 let alice_thinks_bob = derive_sas_code(&alice_secret, &mallory_pub, &tx_id);
279 let bob_thinks_alice = derive_sas_code(&bob_secret, &mallory_pub, &tx_id);
280 assert_ne!(alice_thinks_bob, bob_thinks_alice);
281
282 let alice_real = derive_sas_code(&alice_secret, &bob_pub, &tx_id);
284 let bob_real = derive_sas_code(&bob_secret, &alice_pub, &tx_id);
285 assert_eq!(alice_real, bob_real);
286 }
287
288 #[test]
289 fn pubkey_round_trip() {
290 let (_, _, pub_) = new_session();
291 use base64::engine::general_purpose::STANDARD as B64;
292 use base64::Engine;
293 let encoded = B64.encode(pub_.as_bytes());
294 let decoded = parse_pubkey(&encoded).unwrap();
295 assert_eq!(decoded.as_bytes(), pub_.as_bytes());
296 }
297}