Skip to main content

wire/reception/
sessions.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//! The live session tokens of this machine's door for a browser, as a
7//! state machine (`crates/wire/spec/Server/Sessions.lean` §8-93s): whose each token
8//! is, how many sockets hold it, and when one that no socket holds
9//! lapses.
10//!
11//! Pure: every transition is handed its time. A token is judged at the
12//! moment it is asked about, so a lapsed token needs no sweep to stop
13//! admitting; minting a new one clears the lapsed ones so the table does
14//! not grow.
15
16use std::collections::BTreeMap;
17use std::num::NonZeroUsize;
18
19use kernel::{B3Hash, TimeMs};
20
21use crate::answer::DeviceId;
22
23/// Live session tokens at once; the oldest idle one goes first.
24pub const SESSIONS_MAX: usize = 64;
25/// How long a token no socket holds stays good (wire D55).
26pub const SESSION_IDLE_MS: u64 = 60_000;
27
28/// Whether sockets hold a token now, or since when none has.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30enum Hold {
31    Seated(NonZeroUsize),
32    Idle(TimeMs),
33}
34
35/// One token: the device that signed for it, its place in the order of
36/// minting, and who holds it.
37#[derive(Debug, Clone, PartialEq, Eq)]
38struct Session {
39    device: DeviceId,
40    minted: u64,
41    hold: Hold,
42}
43
44impl Session {
45    /// Whether no socket holds the token.
46    fn idle(&self) -> bool {
47        match self.hold {
48            Hold::Seated(_) => false,
49            Hold::Idle(_) => true,
50        }
51    }
52
53    /// Whether the token still admits at `now`.
54    fn live(&self, now: TimeMs) -> bool {
55        match self.hold {
56            Hold::Seated(_) => true,
57            Hold::Idle(since) => now.value() < since.value().saturating_add(SESSION_IDLE_MS),
58        }
59    }
60}
61
62/// The live session tokens, by digest, each belonging to one device.
63#[derive(Debug, Clone, Default, PartialEq, Eq)]
64pub struct Sessions {
65    live: BTreeMap<B3Hash, Session>,
66    minted: u64,
67}
68
69impl Sessions {
70    /// Whether `presented` is a session token that admits at `now`.
71    #[must_use]
72    pub fn holds(&self, presented: &str, now: TimeMs) -> bool {
73        self.live
74            .get(&B3Hash::digest(presented.as_bytes()))
75            .is_some_and(|session| session.live(now))
76    }
77
78    /// Keeps `token` for `device`, idle from `now`, after clearing the
79    /// lapsed tokens and, while the table is full, the oldest idle one -
80    /// or the oldest of all when sockets hold every one.
81    pub(super) fn mint(&mut self, device: DeviceId, token: &str, now: TimeMs) {
82        self.live.retain(|_, session| session.live(now));
83        while self.live.len() >= SESSIONS_MAX {
84            let oldest = |idle_only: bool| {
85                self.live
86                    .iter()
87                    .filter(|(_, session)| !idle_only || session.idle())
88                    .min_by_key(|(_, session)| session.minted)
89                    .map(|(digest, _)| *digest)
90            };
91            let Some(evicted) = oldest(true).or_else(|| oldest(false)) else {
92                break;
93            };
94            self.live.remove(&evicted);
95        }
96        self.minted = self.minted.saturating_add(1);
97        self.live.insert(
98            B3Hash::digest(token.as_bytes()),
99            Session {
100                device,
101                minted: self.minted,
102                hold: Hold::Idle(now),
103            },
104        );
105    }
106
107    /// Seats one socket on `presented` when it is a token that admits at
108    /// `now`, and names the token so the socket can leave its seat; `None`
109    /// for anything else, the native key included.
110    pub(crate) fn seat(&mut self, presented: &str, now: TimeMs) -> Option<B3Hash> {
111        let digest = B3Hash::digest(presented.as_bytes());
112        let session = self
113            .live
114            .get_mut(&digest)
115            .filter(|session| session.live(now))?;
116        session.hold = Hold::Seated(match session.hold {
117            Hold::Seated(sockets) => sockets.saturating_add(1),
118            Hold::Idle(_) => NonZeroUsize::MIN,
119        });
120        Some(digest)
121    }
122
123    /// One socket seated on `digest` has ended; the last one to leave
124    /// starts the idle allowance at `now`.
125    pub(crate) fn unseat(&mut self, digest: &B3Hash, now: TimeMs) {
126        if let Some(session) = self.live.get_mut(digest)
127            && let Hold::Seated(sockets) = session.hold
128        {
129            session.hold = NonZeroUsize::new(sockets.get().saturating_sub(1))
130                .map_or(Hold::Idle(now), Hold::Seated);
131        }
132    }
133
134    /// Ends the token `digest` at once, whoever holds it.
135    pub(crate) fn end(&mut self, digest: &B3Hash) {
136        self.live.remove(digest);
137    }
138
139    /// Ends every token `device` signed for.
140    pub(super) fn end_device(&mut self, device: &DeviceId) {
141        self.live.retain(|_, session| &session.device != device);
142    }
143}
144
145#[cfg(test)]
146#[allow(
147    clippy::unwrap_used,
148    clippy::indexing_slicing,
149    clippy::arithmetic_side_effects,
150    reason = "test code"
151)]
152mod tests {
153    use std::collections::{BTreeMap, BTreeSet};
154
155    use proptest::prelude::*;
156
157    use super::*;
158    use crate::reception::pairing::{BrowserDoor, DeviceKey, PairedBrowser};
159
160    /// One step of the model's trace (`Wire.Server.Sessions.Step`), with
161    /// the clock moving between steps. Devices and tokens are picked by
162    /// index, so a trace keeps meeting the ones it already made.
163    #[derive(Debug, Clone)]
164    enum Move {
165        Pair(u8),
166        Mint(u8),
167        Seat(usize),
168        Unseat(usize),
169        Forget(u8),
170        Wait(u64),
171    }
172
173    fn a_move() -> impl Strategy<Value = Move> {
174        prop_oneof![
175            (0u8..3).prop_map(Move::Pair),
176            (0u8..3).prop_map(Move::Mint),
177            (0usize..8).prop_map(Move::Seat),
178            (0usize..8).prop_map(Move::Unseat),
179            (0u8..3).prop_map(Move::Forget),
180            (0u64..2 * SESSION_IDLE_MS).prop_map(Move::Wait),
181        ]
182    }
183
184    /// What the trace did to one token, as the three properties read it.
185    struct Minted {
186        token: String,
187        device: DeviceId,
188        seats: usize,
189        idle_since: u64,
190        lapsed: bool,
191    }
192
193    proptest! {
194        /// `crates/wire/spec/Server/Sessions.lean` §8-93s over random traces that
195        /// never fill the table: a token no socket holds admits for
196        /// `idle` and no longer (`Session.live`); a forgotten device that is not paired
197        /// again has no token that admits (`a_forgotten_device_keeps_no_session`);
198        /// a token that has stopped admitting never admits again
199        /// (`an_expired_token_never_admits_again`); a token a socket
200        /// holds admits however long passes (`a_seated_token_outlasts_its_idle_time`).
201        #[test]
202        fn forgotten_and_lapsed_tokens_stay_ended_and_seated_ones_stay_good(
203            moves in proptest::collection::vec(a_move(), 0..48),
204        ) {
205            let mut door = BrowserDoor::new(Vec::new(), [0; 32]);
206            let mut now = 0u64;
207            let mut minted: Vec<Minted> = Vec::new();
208            let mut devices: BTreeMap<u8, DeviceId> = BTreeMap::new();
209            let mut forgotten: BTreeSet<DeviceId> = BTreeSet::new();
210            for (step, each) in moves.into_iter().enumerate() {
211                let at = TimeMs::new(now);
212                match each {
213                    Move::Pair(which) => {
214                        let line = PairedBrowser::new(DeviceKey::parse(&format!("{which:02x}").repeat(32)).unwrap(), "browser".to_owned(), at);
215                        let device = door.pair(line);
216                        forgotten.remove(&device);
217                        devices.insert(which, device);
218                    }
219                    Move::Mint(which) => {
220                        let Some(device) = devices.get(&which) else { continue };
221                        let entropy = [u8::try_from(step).unwrap(); 32];
222                        if let Some(token) = door.open_session(device, entropy, at) {
223                            minted.push(Minted { token, device: device.clone(), seats: 0, idle_since: now, lapsed: false });
224                        }
225                    }
226                    Move::Seat(pick) => {
227                        let Some(token) = minted.get_mut(pick) else { continue };
228                        if door.sessions_mut().seat(&token.token, at).is_some() {
229                            token.seats += 1;
230                        }
231                    }
232                    Move::Unseat(pick) => {
233                        let Some(token) = minted.get_mut(pick) else { continue };
234                        if token.seats > 0 {
235                            door.sessions_mut().unseat(&B3Hash::digest(token.token.as_bytes()), at);
236                            token.seats -= 1;
237                            token.idle_since = now;
238                        }
239                    }
240                    Move::Forget(which) => {
241                        let Some(device) = devices.get(&which) else { continue };
242                        door.forget(device);
243                        forgotten.insert(device.clone());
244                        for token in minted.iter_mut().filter(|token| &token.device == device) {
245                            token.seats = 0;
246                        }
247                    }
248                    Move::Wait(ms) => now += ms,
249                }
250                let at = TimeMs::new(now);
251                for token in &mut minted {
252                    let admits = door.sessions().holds(&token.token, at);
253                    if forgotten.contains(&token.device) {
254                        prop_assert!(!admits, "a token of a forgotten device admits");
255                    }
256                    if token.lapsed {
257                        prop_assert!(!admits, "a token admits again after it stopped");
258                    }
259                    if token.seats > 0 {
260                        prop_assert!(admits, "a token a socket holds stopped admitting");
261                    } else if now >= token.idle_since + SESSION_IDLE_MS {
262                        prop_assert!(!admits, "an idle token outlived its allowance");
263                    }
264                    token.lapsed = !admits;
265                }
266            }
267        }
268    }
269}