Skip to main content

wire/reception/
admission.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//! Whether one HTTP request may reach the work behind a door.
7//!
8//! The socket's peer is judged at the hello frame, and judged again
9//! whenever the door ends a session token, so a forgotten browser loses
10//! the sockets it already opened (`crates/wire/spec/Server/Sessions.lean` §8-93s);
11//! a POST has no session, so every request is judged on its own. Both
12//! judgements are [`Keys::pairing`], and they live in `reception` rather
13//! than in the shell that calls them.
14
15use kernel::{AxCode, AxError, TimeMs};
16
17use super::BindFace;
18use super::sessions::Sessions;
19use crate::auth::{self, Pairing};
20
21/// Every credential this city accepts at this moment: the face's key,
22/// and the browsers' session tokens that admit at `now`. One value,
23/// because the two are one question
24/// (`crates/wire/spec/Reception/Admission.lean` §8-40).
25#[derive(Debug, Clone, Copy)]
26pub struct Keys<'a> {
27    pub face: &'a BindFace,
28    pub sessions: &'a Sessions,
29    pub now: TimeMs,
30}
31
32impl Keys<'_> {
33    /// Whether `offered` is this city's key or a live session token.
34    #[must_use]
35    pub fn pairing(&self, offered: Option<&str>) -> Pairing {
36        let Some(offered) = offered else {
37            return Pairing::Absent;
38        };
39        if auth::verify(Some(offered), self.face.key()) || self.sessions.holds(offered, self.now) {
40            Pairing::Held
41        } else {
42            Pairing::Absent
43        }
44    }
45}
46
47/// What a live socket does once the door may have ended a session token:
48/// it goes on while the credential its hello showed still admits, and
49/// ends with the refusal once it does not.
50#[derive(Debug)]
51pub enum Standing {
52    Stands,
53    Ends(Box<AxError>),
54}
55
56/// Judges a live socket again, from what [`Keys::pairing`] says now of
57/// the credential its hello showed. The native key always stands; a
58/// session token stands until its device is forgotten.
59#[must_use]
60pub fn decide_standing(pairing: Pairing) -> Standing {
61    match pairing {
62        Pairing::Held => Standing::Stands,
63        Pairing::Absent => Standing::Ends(Box::new(
64            AxError::failure(
65                AxCode::GateDenied,
66                "keep a browser session open",
67                "this browser was forgotten, or its session token has ended",
68            )
69            .with_recovery(
70                "pair this browser again with the pairing code the terminal shows; a browser \
71                 the city still knows reconnects by itself",
72            ),
73        )),
74    }
75}
76
77/// Which HTTP door a request arrived at.
78///
79/// Exhaustive, because the pairing rule is not the same at all three:
80/// a door that answers a stranger with a refusal and a door that lets
81/// the refusal be worded further inward are different rules, and a
82/// boolean would let a new door inherit whichever one was nearer.
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum Door {
85    /// `POST /transcribe`: bytes that cost money at a provider.
86    Transcribe,
87    /// `POST /enroll`: a credential on its way to the vault.
88    Enroll,
89    /// `POST /acp`: an outside editor driving the city.
90    Acp,
91    /// `POST /drop`: a file written onto the city's disk.
92    Drop,
93}
94
95/// What a door does with one request, before any of its bytes are read.
96#[derive(Debug)]
97pub enum Admission {
98    /// Let it through, carrying whether the caller held the token. Only
99    /// [`Door::Acp`] is ever admitted unpaired.
100    Admit(Pairing),
101    Refuse(AxError),
102}
103
104/// Decides whether one HTTP request may reach the work behind a door,
105/// once [`Keys::pairing`] has judged what it offered.
106///
107/// **The one place an HTTP caller's credential decides a door.** Every
108/// face demands a key, so a caller with nothing is turned away from the
109/// three doors that act wherever the city listens.
110///
111/// [`Door::Acp`] is admitted without one on purpose: what an
112/// unauthenticated editor may learn is `agent_protocols::admit`'s to
113/// word, and it words it so that a stranger learns exactly one bit.
114#[must_use]
115pub fn decide_admission(door: Door, pairing: Pairing) -> Admission {
116    let action = match (pairing, door) {
117        (Pairing::Held, _) => return Admission::Admit(Pairing::Held),
118        (Pairing::Absent, Door::Acp) => return Admission::Admit(Pairing::Absent),
119        (Pairing::Absent, Door::Transcribe) => "transcribe a recording",
120        (Pairing::Absent, Door::Enroll) => "enrol a credential",
121        (Pairing::Absent, Door::Drop) => "keep a dropped file",
122    };
123    Admission::Refuse(
124        AxError::failure(
125            AxCode::GateDenied,
126            action,
127            "the request carried neither this city's key nor a live session token",
128        )
129        .with_recovery(
130            "send the session token or the city's key as an `Authorization: Bearer` header; \
131             a program on this machine reads the key from the city's key file",
132        ),
133    )
134}
135
136/// The one spelling of how an HTTP caller offers the pairing token.
137///
138/// The socket offers it inside the hello frame, where the frame type
139/// names the field; a POST has no frame, so it carries the standard
140/// bearer header and this function is where that scheme is read. A
141/// value that is not a bearer token is no token at all rather than a
142/// token that happens to fail, so a caller cannot learn the scheme by
143/// watching which refusal it gets.
144#[must_use]
145pub fn offered_pairing(header: Option<&str>) -> Option<&str> {
146    let raw = header?.trim();
147    let (scheme, token) = raw.split_once(' ')?;
148    scheme
149        .eq_ignore_ascii_case("Bearer")
150        .then(|| token.trim())
151        .filter(|token| !token.is_empty())
152}
153
154#[cfg(test)]
155#[allow(clippy::panic, reason = "test code")]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn the_acting_doors_refuse_an_empty_hand_and_the_editor_door_carries_the_bit() {
161        for door in [Door::Transcribe, Door::Enroll, Door::Drop] {
162            let Admission::Refuse(err) = decide_admission(door, Pairing::Absent) else {
163                panic!("{door:?} acts on a request, so it refuses an unpaired one");
164            };
165            assert_eq!(*err.code(), AxCode::GateDenied);
166        }
167        assert!(matches!(
168            decide_admission(Door::Acp, Pairing::Absent),
169            Admission::Admit(Pairing::Absent)
170        ));
171    }
172
173    #[test]
174    fn the_key_and_nothing_else_is_held_on_a_loopback_face() {
175        let face = BindFace::Loopback {
176            key: kernel::B3Hash::digest(b"native-key"),
177        };
178        let sessions = Sessions::default();
179        let keys = Keys {
180            face: &face,
181            sessions: &sessions,
182            now: kernel::TimeMs::new(0),
183        };
184        assert_eq!(keys.pairing(Some("native-key")), Pairing::Held);
185        assert_eq!(keys.pairing(Some("guess")), Pairing::Absent);
186        assert_eq!(keys.pairing(None), Pairing::Absent);
187    }
188
189    #[test]
190    fn only_a_bearer_header_offers_a_token() {
191        assert_eq!(offered_pairing(Some("Bearer abc")), Some("abc"));
192        assert_eq!(offered_pairing(Some("bearer  abc ")), Some("abc"));
193        assert_eq!(offered_pairing(Some("Basic abc")), None);
194        assert_eq!(offered_pairing(Some("abc")), None);
195        assert_eq!(offered_pairing(Some("Bearer ")), None);
196        assert_eq!(offered_pairing(None), None);
197    }
198}