Skip to main content

wire/server/
door.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 handle on this machine's door for a browser
7//! (`crates/wire/spec/Server.lean` §8-93): one lock around
8//! [`BrowserDoor`], the clock and random source the assembly layer hands
9//! in, and the closure the device table is kept through.
10//!
11//! Every rule is the state machines' (`reception::pairing`,
12//! `reception::sessions`); what is left here is drawing entropy and time
13//! for them, checking a signature, telling whoever handed out an open code
14//! that it was redeemed, and telling every socket when a session token may
15//! have ended, so a forgotten browser loses the sockets it holds
16//! (`crates/wire/spec/Server/Sessions.lean` §8-93s).
17
18use std::collections::BTreeMap;
19use std::sync::mpsc;
20use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
21
22use aws_lc_rs::signature::{ED25519, UnparsedPublicKey};
23use kernel::{AxCode, AxError, B3Hash, TimeMs};
24use tokio::sync::watch;
25
26use crate::answer::{DeviceId, DevicesAnswer};
27use crate::auth::Pairing;
28use crate::reception::{
29    BindFace, BrowserDoor, DeviceKey, Guess, Keys, LABEL_MAX, PairedBrowser, Sessions, decode_hex,
30};
31
32use super::pairing::{PairProof, SessionBody};
33use super::seat::Seat;
34
35/// The label every session signature starts with, so a signature made
36/// for this door is not one any other protocol accepts.
37const SIGNED_LABEL: &str = "sprawling local session v1";
38
39/// Where the door keeps its table: the whole table, after every change.
40pub type KeepBrowsers = Arc<dyn Fn(&[PairedBrowser]) -> Result<(), AxError> + Send + Sync>;
41
42/// The clock the door reads the time from.
43pub type DoorClock = Arc<dyn Fn() -> Result<TimeMs, AxError> + Send + Sync>;
44
45/// The random source the door fills its codes, nonces and tokens from.
46pub type DoorEntropy = Arc<dyn Fn(&mut [u8]) -> Result<(), AxError> + Send + Sync>;
47
48/// The clock and the random source the door is handed; it samples neither
49/// itself.
50#[derive(Clone)]
51pub struct DoorSenses {
52    pub clock: DoorClock,
53    pub entropy: DoorEntropy,
54}
55
56/// An open code on its way to a browser, and the signal that it arrived.
57pub struct OpenCode {
58    pub code: String,
59    /// Receives once when the code is redeemed. Dropped unanswered when
60    /// the code expired or the door went away.
61    pub redeemed: mpsc::Receiver<()>,
62}
63
64/// This machine's door for a browser. Cloning shares the one door.
65#[derive(Clone)]
66pub struct LocalDoor(Arc<Inner>);
67
68struct Inner {
69    held: Mutex<Held>,
70    senses: DoorSenses,
71    keep: KeepBrowsers,
72    code: watch::Sender<String>,
73    /// Sent whenever a session token may have ended while a socket held
74    /// it: a device forgotten, or the table full.
75    ended: watch::Sender<()>,
76}
77
78struct Held {
79    door: BrowserDoor,
80    waiting: BTreeMap<B3Hash, mpsc::Sender<()>>,
81}
82
83impl LocalDoor {
84    /// A door that knows `browsers`, showing a fresh pairing code.
85    ///
86    /// # Errors
87    /// The random source refuses.
88    pub fn new(
89        browsers: Vec<PairedBrowser>,
90        senses: DoorSenses,
91        keep: KeepBrowsers,
92    ) -> Result<Self, AxError> {
93        let door = BrowserDoor::new(browsers, drawn(&senses)?);
94        let (code, _) = watch::channel(door.pairing_code().to_owned());
95        let (ended, _) = watch::channel(());
96        Ok(Self(Arc::new(Inner {
97            held: Mutex::new(Held {
98                door,
99                waiting: BTreeMap::new(),
100            }),
101            senses,
102            keep,
103            code,
104            ended,
105        })))
106    }
107
108    /// The pairing code shown now, updated every time a guess replaces it.
109    #[must_use]
110    pub fn pairing_code(&self) -> watch::Receiver<String> {
111        self.0.code.subscribe()
112    }
113
114    /// Mints an open code for `/web` to hand over.
115    ///
116    /// # Errors
117    /// The clock or the random source refuses.
118    pub fn issue_open_code(&self) -> Result<OpenCode, AxError> {
119        let entropy: [u8; 16] = drawn(&self.0.senses)?;
120        let now = (self.0.senses.clock)()?;
121        let (tell, redeemed) = mpsc::channel();
122        let mut held = self.held();
123        let code = held.door.issue_open_code(entropy, now);
124        held.waiting.insert(B3Hash::digest(code.as_bytes()), tell);
125        Ok(OpenCode { code, redeemed })
126    }
127
128    /// Every paired browser.
129    #[must_use]
130    pub fn devices(&self) -> DevicesAnswer {
131        self.held().door.devices()
132    }
133
134    /// Forgets a paired browser and ends its sessions, closing every
135    /// socket that holds one of them; false when it was not paired.
136    ///
137    /// # Errors
138    /// The table could not be kept; the browser stays paired.
139    pub fn forget(&self, device: &DeviceId) -> Result<bool, AxError> {
140        let mut held = self.held();
141        let table: Vec<PairedBrowser> = held
142            .door
143            .browsers()
144            .into_iter()
145            .filter(|browser| &browser.id != device)
146            .collect();
147        (self.0.keep)(&table)?;
148        let forgotten = held.door.forget(device);
149        self.0.ended.send_replace(());
150        Ok(forgotten)
151    }
152
153    /// Runs `judge` over the session tokens and the moment they are
154    /// judged at.
155    ///
156    /// # Errors
157    /// The clock refuses; nothing is judged.
158    pub(crate) fn with_sessions<R>(
159        &self,
160        judge: impl FnOnce(&Sessions, TimeMs) -> R,
161    ) -> Result<R, AxError> {
162        let now = (self.0.senses.clock)()?;
163        Ok(judge(self.held().door.sessions(), now))
164    }
165
166    /// Whether `offered` is the key `face` demands or a session token
167    /// that admits now.
168    ///
169    /// # Errors
170    /// The clock refuses.
171    pub(crate) fn pairing(
172        &self,
173        face: &BindFace,
174        offered: Option<&str>,
175    ) -> Result<Pairing, AxError> {
176        self.with_sessions(|sessions, now| {
177            Keys {
178                face,
179                sessions,
180                now,
181            }
182            .pairing(offered)
183        })
184    }
185
186    /// Seats a socket greeted with `presented`: a session token that
187    /// admits now is held for as long as the seat lives, so it does not
188    /// lapse under an open socket; the native key takes no seat.
189    ///
190    /// The seat starts marked as ended, so the socket judges its
191    /// credential once more at once: a token ended between the hello's
192    /// judgement and this seat ends the socket as well.
193    ///
194    /// # Errors
195    /// The clock refuses.
196    pub(crate) fn seat(&self, presented: Option<String>) -> Result<Seat, AxError> {
197        let now = (self.0.senses.clock)()?;
198        let mut held = self.held();
199        let mut ended = self.0.ended.subscribe();
200        ended.mark_changed();
201        let token = presented
202            .as_deref()
203            .and_then(|token| held.door.sessions_mut().seat(token, now));
204        Ok(Seat {
205            door: self.clone(),
206            presented,
207            token,
208            ended,
209        })
210    }
211
212    /// Pairs a browser that presented one of the two codes.
213    pub(crate) fn pair(
214        &self,
215        proof: &PairProof,
216        public_key: &str,
217        label: &str,
218    ) -> Result<DeviceId, AxError> {
219        let key = DeviceKey::parse(public_key).ok_or_else(|| {
220            invalid(
221                "public_key",
222                "send the 32 bytes of the Ed25519 public key as 64 hex characters",
223            )
224        })?;
225        let label = readable_label(label)?;
226        let now = (self.0.senses.clock)()?;
227        let entropy: [u8; 32] = drawn(&self.0.senses)?;
228        let mut held = self.held();
229        match proof {
230            PairProof::Code(word) => match held.door.guess(word, now, entropy) {
231                Guess::Paired => {
232                    self.0
233                        .code
234                        .send_replace(held.door.pairing_code().to_owned());
235                }
236                Guess::Wrong => {
237                    self.0
238                        .code
239                        .send_replace(held.door.pairing_code().to_owned());
240                    return Err(refused("type the pairing code the terminal shows now"));
241                }
242                Guess::TooSoon => {
243                    return Err(refused(
244                        "wait a second, then type the code the terminal shows",
245                    ));
246                }
247            },
248            PairProof::Open(code) => {
249                let Some(digest) = held.door.redeem_open_code(code, now) else {
250                    return Err(refused(
251                        "open the city again from its terminal, or type the pairing code it shows",
252                    ));
253                };
254                if let Some(tell) = held.waiting.remove(&digest) {
255                    // A send that finds nobody means the opener stopped
256                    // waiting, and its file went with it: nothing is owed.
257                    let _unheard: Result<(), mpsc::SendError<()>> = tell.send(());
258                }
259            }
260        }
261        let line = PairedBrowser::new(key, label, now);
262        let mut table: Vec<PairedBrowser> = held
263            .door
264            .browsers()
265            .into_iter()
266            .filter(|browser| browser.id != line.id)
267            .collect();
268        table.push(line.clone());
269        (self.0.keep)(&table)?;
270        Ok(held.door.pair(line))
271    }
272
273    /// One socket seated on `token` has ended; the last one to leave
274    /// starts the token's idle allowance now.
275    pub(super) fn unseat(&self, token: &B3Hash) {
276        let now = (self.0.senses.clock)();
277        let mut held = self.held();
278        match now {
279            Ok(now) => held.door.sessions_mut().unseat(token, now),
280            // Without a time the idle allowance cannot start, so the
281            // token ends now; the page signs a new one before it dials.
282            Err(_) => held.door.sessions_mut().end(token),
283        }
284    }
285
286    /// A nonce for a paired browser to sign.
287    pub(crate) fn challenge(&self) -> Result<String, AxError> {
288        let entropy: [u8; 32] = drawn(&self.0.senses)?;
289        let now = (self.0.senses.clock)()?;
290        Ok(self.held().door.challenge(entropy, now))
291    }
292
293    /// Opens a session for a paired browser whose signature over
294    /// `nonce`, `origin` and the label verifies.
295    pub(crate) fn open_session(&self, body: &SessionBody, origin: &str) -> Result<String, AxError> {
296        let now = (self.0.senses.clock)()?;
297        let entropy: [u8; 32] = drawn(&self.0.senses)?;
298        let mut held = self.held();
299        if !held.door.take_nonce(&body.nonce, now) {
300            return Err(unsigned(
301                "ask /session/challenge for a new nonce and sign it",
302            ));
303        }
304        let Some(key) = held.door.device_key(&body.device).copied() else {
305            return Err(unsigned(
306                "pair this browser again: the city does not know it",
307            ));
308        };
309        let signature: [u8; 64] = decode_hex(&body.signature)
310            .ok_or_else(|| unsigned("send the 64-byte Ed25519 signature as 128 hex characters"))?;
311        let signed = format!("{SIGNED_LABEL}\n{}\n{origin}", body.nonce);
312        UnparsedPublicKey::new(&ED25519, key.bytes())
313            .verify(signed.as_bytes(), &signature)
314            .map_err(|_| unsigned("pair this browser again: its signature does not verify"))?;
315        let token = held
316            .door
317            .open_session(&body.device, entropy, now)
318            .ok_or_else(|| unsigned("pair this browser again: the city does not know it"))?;
319        // A full table ends its oldest token, which a socket may hold.
320        self.0.ended.send_replace(());
321        Ok(token)
322    }
323
324    /// The state, even after a holder panicked: every transition leaves it
325    /// whole, so a poisoned lock holds nothing half-written.
326    fn held(&self) -> MutexGuard<'_, Held> {
327        self.0.held.lock().unwrap_or_else(PoisonError::into_inner)
328    }
329}
330
331/// `N` bytes from the door's random source.
332fn drawn<const N: usize>(senses: &DoorSenses) -> Result<[u8; N], AxError> {
333    let mut bytes = [0u8; N];
334    (senses.entropy)(&mut bytes)?;
335    Ok(bytes)
336}
337
338/// A label a person can read: not empty, no control characters, at most
339/// [`LABEL_MAX`] characters.
340fn readable_label(label: &str) -> Result<String, AxError> {
341    let label = label.trim();
342    if label.is_empty() || label.chars().count() > LABEL_MAX || label.chars().any(char::is_control)
343    {
344        return Err(invalid(
345            "label",
346            "name the browser in 1 to 64 characters, with no control characters",
347        ));
348    }
349    Ok(label.to_owned())
350}
351
352fn refused(recovery: &str) -> AxError {
353    AxError::failure(
354        AxCode::PairingRefused,
355        "pair a browser",
356        "the code was wrong, used, expired or sent too soon",
357    )
358    .with_recovery(recovery.to_owned())
359}
360
361fn unsigned(recovery: &str) -> AxError {
362    AxError::failure(
363        AxCode::GateDenied,
364        "open a browser session",
365        "the challenge was not answered by a paired browser",
366    )
367    .with_recovery(recovery.to_owned())
368}
369
370fn invalid(field: &str, recovery: &str) -> AxError {
371    AxError::failure(
372        AxCode::InvalidArgs,
373        "pair a browser",
374        format!("`{field}` cannot be read"),
375    )
376    .with_recovery(recovery.to_owned())
377}