Skip to main content

wire/reception/
pairing.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! This machine's door for a browser, as a state machine
7//! (`crates/wire/spec/Reception/Pairing.lean` ยง8-95): the pairing code the
8//! terminal shows, one guess at a time; the open codes `/web` hands over;
9//! the nonces a session is signed over; the paired device keys; and the
10//! live session tokens, whose table and lifetime are `reception::sessions`'s.
11//!
12//! Pure: every transition is handed its time and its entropy, so the
13//! rules can be driven step by step. The lock, the clock, the random
14//! source and the file the device table is kept in are the shell's
15//! (`server::door`).
16
17use std::collections::BTreeMap;
18
19use kernel::{B3Hash, TimeMs};
20
21use super::sessions::Sessions;
22use crate::answer::{DeviceId, DeviceLine, DevicesAnswer};
23use crate::auth;
24
25/// The least time between two judged guesses of the pairing code. One
26/// window for every caller, so a guesser gains nothing from more sockets.
27pub const GUESS_INTERVAL_MS: u64 = 1_000;
28/// How long an open code stays redeemable.
29pub const OPEN_CODE_LIFETIME_MS: u64 = 120_000;
30/// Open codes outstanding at once; the oldest goes first.
31pub const OPEN_CODES_MAX: usize = 4;
32/// How long a session challenge stays answerable.
33pub const NONCE_LIFETIME_MS: u64 = 60_000;
34/// Challenges outstanding at once; the oldest goes first.
35pub const NONCES_MAX: usize = 16;
36/// The longest name a page may give itself.
37pub const LABEL_MAX: usize = 64;
38/// The pairing code is two groups of four symbols: about 39 bits, read off
39/// a terminal and typed into a page.
40const CODE_GROUP_LEN: usize = 4;
41const CODE_GROUPS: usize = 2;
42/// Hex characters of a device id: the start of its key's digest.
43const DEVICE_ID_LEN: usize = 16;
44
45/// What a guess of the pairing code came to.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum Guess {
48    /// The word was the current code; the code has been replaced.
49    Paired,
50    /// The word was not the current code; the code has been replaced.
51    Wrong,
52    /// Earlier than the rate allows: not judged, the code unchanged.
53    TooSoon,
54}
55
56/// The public half of a browser's Ed25519 device key.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct DeviceKey([u8; 32]);
59
60impl DeviceKey {
61    /// Reads the 64 lowercase hex characters a page sends.
62    #[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    /// The 64 lowercase hex characters a page sent.
73    #[must_use]
74    pub fn hex(&self) -> String {
75        encode_hex(&self.0)
76    }
77
78    /// The id the city gives the browser holding this key: the same key
79    /// paired twice is the same browser.
80    #[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/// One paired browser, as the city keeps it: public key only.
88#[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    /// A browser pairing now with `key`, under the name it gave.
99    #[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/// The door's whole state.
112#[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    /// A door that knows `browsers` and shows a code made of `entropy`.
124    #[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    /// The pairing code the terminal shows now.
140    #[must_use]
141    pub fn pairing_code(&self) -> &str {
142        &self.code
143    }
144
145    /// Judges one guess of the pairing code. A judged guess, right or
146    /// wrong, replaces the code with one made of `entropy` and holds the
147    /// next judgement back by [`GUESS_INTERVAL_MS`]; a guess that comes
148    /// sooner is not judged at all.
149    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    /// Mints an open code from `entropy`, redeemable once for
160    /// [`OPEN_CODE_LIFETIME_MS`]; only its digest is kept.
161    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    /// Redeems an open code: true once, while it lives, and never again.
171    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    /// Registers a browser, replacing the line of the same key if it
178    /// paired before.
179    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    /// A nonce made of `entropy`, answerable once for [`NONCE_LIFETIME_MS`].
186    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    /// Spends a nonce: true when it was issued and is still alive. Spent
196    /// either way, so a signature is checked against a nonce at most once.
197    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    /// The key a paired browser signs with.
204    #[must_use]
205    pub fn device_key(&self, device: &DeviceId) -> Option<&DeviceKey> {
206        self.browsers.get(device).map(|browser| &browser.key)
207    }
208
209    /// Opens a session for a paired browser that has just signed a
210    /// challenge: a token made of `entropy`, whose digest alone is kept.
211    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    /// The live session tokens.
225    #[must_use]
226    pub fn sessions(&self) -> &Sessions {
227        &self.sessions
228    }
229
230    /// The live session tokens, for a socket to take or leave its seat.
231    pub fn sessions_mut(&mut self) -> &mut Sessions {
232        &mut self.sessions
233    }
234
235    /// Forgets a browser and ends its sessions; false when it was not
236    /// paired.
237    pub fn forget(&mut self, device: &DeviceId) -> bool {
238        self.sessions.end_device(device);
239        self.browsers.remove(device).is_some()
240    }
241
242    /// Every paired browser, as the table is kept.
243    #[must_use]
244    pub fn browsers(&self) -> Vec<PairedBrowser> {
245        self.browsers.values().cloned().collect()
246    }
247
248    /// Every paired browser, as a person recognises and revokes it.
249    #[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
266/// The pairing code `entropy` spells.
267fn code_of(entropy: &[u8; 32]) -> String {
268    auth::spell(entropy, CODE_GROUP_LEN, CODE_GROUPS)
269}
270
271/// Drops the entry that expires first until fewer than `max` remain, so
272/// one more fits.
273fn 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/// Lowercase hex, two characters a byte.
287#[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/// Reads exactly `N` bytes of lowercase or uppercase hex.
302#[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    /// One attempt of the model's trace: how long after the previous one it
338    /// arrives, whether it guesses the code then shown, and the entropy the
339    /// step would replace the code with.
340    #[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        /// `crates/wire/spec/Reception/Pairing.lean`'s three properties over
359        /// random traces of guesses: judged guesses are a second apart
360        /// (`judged_guesses_are_a_second_apart`), each is judged against
361        /// the code the previous judgement put up (`each_code_is_judged_once`),
362        /// and only the code shown at the time pairs (`only_the_current_code_pairs`).
363        #[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}