1use std::collections::BTreeMap;
18
19use kernel::{B3Hash, TimeMs};
20
21use super::sessions::Sessions;
22use crate::answer::{DeviceId, DeviceLine, DevicesAnswer};
23use crate::auth;
24
25pub const GUESS_INTERVAL_MS: u64 = 1_000;
28pub const OPEN_CODE_LIFETIME_MS: u64 = 120_000;
30pub const OPEN_CODES_MAX: usize = 4;
32pub const NONCE_LIFETIME_MS: u64 = 60_000;
34pub const NONCES_MAX: usize = 16;
36pub const LABEL_MAX: usize = 64;
38const CODE_GROUP_LEN: usize = 4;
41const CODE_GROUPS: usize = 2;
42const DEVICE_ID_LEN: usize = 16;
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Guess {
48 Paired,
50 Wrong,
52 TooSoon,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct DeviceKey([u8; 32]);
59
60impl DeviceKey {
61 #[must_use]
63 pub fn parse(text: &str) -> Option<Self> {
64 decode_hex(text).map(Self)
65 }
66
67 #[must_use]
68 pub fn bytes(&self) -> &[u8; 32] {
69 &self.0
70 }
71
72 #[must_use]
74 pub fn hex(&self) -> String {
75 encode_hex(&self.0)
76 }
77
78 #[must_use]
81 pub fn id(&self) -> DeviceId {
82 let digest = encode_hex(B3Hash::digest(&self.0).as_bytes());
83 DeviceId::new(digest.chars().take(DEVICE_ID_LEN).collect())
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct PairedBrowser {
90 pub id: DeviceId,
91 pub label: String,
92 pub key: DeviceKey,
93 pub paired_at: TimeMs,
94 pub last_seen: Option<TimeMs>,
95}
96
97impl PairedBrowser {
98 #[must_use]
100 pub fn new(key: DeviceKey, label: String, now: TimeMs) -> Self {
101 Self {
102 id: key.id(),
103 label,
104 key,
105 paired_at: now,
106 last_seen: None,
107 }
108 }
109}
110
111#[derive(Debug, Clone)]
113pub struct BrowserDoor {
114 code: String,
115 next_guess: TimeMs,
116 open: BTreeMap<B3Hash, TimeMs>,
117 nonces: BTreeMap<String, TimeMs>,
118 browsers: BTreeMap<DeviceId, PairedBrowser>,
119 sessions: Sessions,
120}
121
122impl BrowserDoor {
123 #[must_use]
125 pub fn new(browsers: Vec<PairedBrowser>, entropy: [u8; 32]) -> Self {
126 Self {
127 code: code_of(&entropy),
128 next_guess: TimeMs::new(0),
129 open: BTreeMap::new(),
130 nonces: BTreeMap::new(),
131 browsers: browsers
132 .into_iter()
133 .map(|browser| (browser.id.clone(), browser))
134 .collect(),
135 sessions: Sessions::default(),
136 }
137 }
138
139 #[must_use]
141 pub fn pairing_code(&self) -> &str {
142 &self.code
143 }
144
145 pub fn guess(&mut self, word: &str, now: TimeMs, entropy: [u8; 32]) -> Guess {
150 if now < self.next_guess {
151 return Guess::TooSoon;
152 }
153 let paired = auth::verify(Some(word.trim()), &B3Hash::digest(self.code.as_bytes()));
154 self.code = code_of(&entropy);
155 self.next_guess = TimeMs::new(now.value().saturating_add(GUESS_INTERVAL_MS));
156 if paired { Guess::Paired } else { Guess::Wrong }
157 }
158
159 pub fn issue_open_code(&mut self, entropy: [u8; 16], now: TimeMs) -> String {
162 self.open.retain(|_, expires| *expires > now);
163 evict_oldest(&mut self.open, OPEN_CODES_MAX);
164 let code = encode_hex(&entropy);
165 let expires = TimeMs::new(now.value().saturating_add(OPEN_CODE_LIFETIME_MS));
166 self.open.insert(B3Hash::digest(code.as_bytes()), expires);
167 code
168 }
169
170 pub fn redeem_open_code(&mut self, code: &str, now: TimeMs) -> Option<B3Hash> {
172 let digest = B3Hash::digest(code.trim().as_bytes());
173 let expires = self.open.remove(&digest)?;
174 (expires > now).then_some(digest)
175 }
176
177 pub fn pair(&mut self, line: PairedBrowser) -> DeviceId {
180 let id = line.id.clone();
181 self.browsers.insert(id.clone(), line);
182 id
183 }
184
185 pub fn challenge(&mut self, entropy: [u8; 32], now: TimeMs) -> String {
187 self.nonces.retain(|_, expires| *expires > now);
188 evict_oldest(&mut self.nonces, NONCES_MAX);
189 let nonce = encode_hex(&entropy);
190 let expires = TimeMs::new(now.value().saturating_add(NONCE_LIFETIME_MS));
191 self.nonces.insert(nonce.clone(), expires);
192 nonce
193 }
194
195 pub fn take_nonce(&mut self, nonce: &str, now: TimeMs) -> bool {
198 self.nonces
199 .remove(nonce)
200 .is_some_and(|expires| expires > now)
201 }
202
203 #[must_use]
205 pub fn device_key(&self, device: &DeviceId) -> Option<&DeviceKey> {
206 self.browsers.get(device).map(|browser| &browser.key)
207 }
208
209 pub fn open_session(
212 &mut self,
213 device: &DeviceId,
214 entropy: [u8; 32],
215 now: TimeMs,
216 ) -> Option<String> {
217 let browser = self.browsers.get_mut(device)?;
218 browser.last_seen = Some(now);
219 let token = encode_hex(&entropy);
220 self.sessions.mint(device.clone(), &token, now);
221 Some(token)
222 }
223
224 #[must_use]
226 pub fn sessions(&self) -> &Sessions {
227 &self.sessions
228 }
229
230 pub fn sessions_mut(&mut self) -> &mut Sessions {
232 &mut self.sessions
233 }
234
235 pub fn forget(&mut self, device: &DeviceId) -> bool {
238 self.sessions.end_device(device);
239 self.browsers.remove(device).is_some()
240 }
241
242 #[must_use]
244 pub fn browsers(&self) -> Vec<PairedBrowser> {
245 self.browsers.values().cloned().collect()
246 }
247
248 #[must_use]
250 pub fn devices(&self) -> DevicesAnswer {
251 DevicesAnswer {
252 devices: self
253 .browsers
254 .values()
255 .map(|browser| DeviceLine {
256 id: browser.id.clone(),
257 label: browser.label.clone(),
258 paired_at: browser.paired_at,
259 last_seen: browser.last_seen,
260 })
261 .collect(),
262 }
263 }
264}
265
266fn code_of(entropy: &[u8; 32]) -> String {
268 auth::spell(entropy, CODE_GROUP_LEN, CODE_GROUPS)
269}
270
271fn evict_oldest<K: Ord + Clone>(entries: &mut BTreeMap<K, TimeMs>, max: usize) {
274 while entries.len() >= max {
275 let Some(first) = entries
276 .iter()
277 .min_by_key(|(_, expires)| **expires)
278 .map(|(key, _)| key.clone())
279 else {
280 return;
281 };
282 entries.remove(&first);
283 }
284}
285
286#[must_use]
288pub fn encode_hex(bytes: &[u8]) -> String {
289 const DIGITS: &[u8; 16] = b"0123456789abcdef";
290 let mut text = String::new();
291 for byte in bytes {
292 for nibble in [byte.wrapping_shr(4), byte & 0x0f] {
293 if let Some(digit) = DIGITS.get(usize::from(nibble)) {
294 text.push(char::from(*digit));
295 }
296 }
297 }
298 text
299}
300
301#[must_use]
303pub fn decode_hex<const N: usize>(text: &str) -> Option<[u8; N]> {
304 let digits = text.as_bytes();
305 if digits.len() != N.checked_mul(2)? {
306 return None;
307 }
308 let (pairs, rest) = digits.as_chunks::<2>();
309 if !rest.is_empty() {
310 return None;
311 }
312 let mut bytes = [0u8; N];
313 for (slot, [high, low]) in bytes.iter_mut().zip(pairs) {
314 let value = |digit: u8| {
315 char::from(digit)
316 .to_digit(16)
317 .and_then(|v| u8::try_from(v).ok())
318 };
319 *slot = value(*high)?.checked_mul(16)?.checked_add(value(*low)?)?;
320 }
321 Some(bytes)
322}
323
324#[cfg(test)]
325#[allow(
326 clippy::unwrap_used,
327 clippy::expect_used,
328 clippy::panic,
329 clippy::indexing_slicing,
330 clippy::arithmetic_side_effects,
331 reason = "test code"
332)]
333mod tests {
334 use super::*;
335 use proptest::prelude::*;
336
337 #[derive(Debug, Clone)]
341 struct Attempt {
342 after_ms: u64,
343 right: bool,
344 entropy: [u8; 32],
345 }
346
347 fn attempt() -> impl Strategy<Value = Attempt> {
348 (0u64..2_500, any::<bool>(), any::<[u8; 32]>()).prop_map(|(after_ms, right, entropy)| {
349 Attempt {
350 after_ms,
351 right,
352 entropy,
353 }
354 })
355 }
356
357 proptest! {
358 #[test]
364 fn every_trace_judges_each_code_once_and_a_second_apart(
365 first in any::<[u8; 32]>(),
366 attempts in proptest::collection::vec(attempt(), 0..40),
367 ) {
368 let mut door = BrowserDoor::new(Vec::new(), first);
369 let mut now = 0u64;
370 let mut last_judged: Option<u64> = None;
371 let mut expected_code = code_of(&first);
372 for step in attempts {
373 now += step.after_ms;
374 let shown = door.pairing_code().to_owned();
375 prop_assert_eq!(&shown, &expected_code);
376 let word = if step.right { shown.clone() } else { format!("{shown}x") };
377 let verdict = door.guess(&word, TimeMs::new(now), step.entropy);
378 let early = last_judged.is_some_and(|at| now < at + GUESS_INTERVAL_MS);
379 if early {
380 prop_assert_eq!(verdict, Guess::TooSoon);
381 prop_assert_eq!(door.pairing_code(), shown.as_str());
382 continue;
383 }
384 prop_assert_eq!(verdict, if step.right { Guess::Paired } else { Guess::Wrong });
385 if let Some(at) = last_judged {
386 prop_assert!(now >= at + GUESS_INTERVAL_MS);
387 }
388 last_judged = Some(now);
389 expected_code = code_of(&step.entropy);
390 prop_assert_eq!(door.pairing_code(), expected_code.as_str());
391 }
392 }
393 }
394
395 #[test]
396 fn an_open_code_is_redeemed_once_and_not_after_it_expires() {
397 let mut door = BrowserDoor::new(Vec::new(), [1; 32]);
398 let code = door.issue_open_code([2; 16], TimeMs::new(0));
399 assert!(door.redeem_open_code(&code, TimeMs::new(10)).is_some());
400 assert!(door.redeem_open_code(&code, TimeMs::new(11)).is_none());
401 let late = door.issue_open_code([3; 16], TimeMs::new(0));
402 assert!(
403 door.redeem_open_code(&late, TimeMs::new(OPEN_CODE_LIFETIME_MS))
404 .is_none()
405 );
406 }
407
408 #[test]
409 fn hex_reads_back_what_it_wrote() {
410 let bytes = [0x00, 0x7f, 0xa5, 0xff];
411 assert_eq!(decode_hex::<4>(&encode_hex(&bytes)), Some(bytes));
412 assert_eq!(decode_hex::<4>("00ff"), None);
413 assert_eq!(decode_hex::<2>("zz00"), None);
414 }
415}