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 once, at the hello frame, and stays
9//! judged for the life of the session; a POST has no session, so every
10//! request is judged on its own. That is the whole difference between
11//! this file and [`super::decide_handshake`], and it is why both of
12//! them live in `reception` rather than in the shell that calls them.
13
14use kernel::{AxCode, AxError};
15
16use super::BindFace;
17use crate::auth::{self, Pairing};
18
19/// Which HTTP door a request arrived at.
20///
21/// Exhaustive, because the pairing rule is not the same at all three:
22/// a door that answers a stranger with a refusal and a door that lets
23/// the refusal be worded further inward are different rules, and a
24/// boolean would let a new door inherit whichever one was nearer.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Door {
27    /// `POST /transcribe`: bytes that cost money at a provider.
28    Transcribe,
29    /// `POST /enroll`: a credential on its way to the vault.
30    Enroll,
31    /// `POST /acp`: an outside editor driving the city.
32    Acp,
33    /// `POST /drop`: a file written onto the city's disk.
34    Drop,
35}
36
37/// What a door does with one request, before any of its bytes are read.
38#[derive(Debug)]
39pub enum Admission {
40    /// Let it through, carrying whether the caller held the token. Only
41    /// [`Door::Acp`] is ever admitted unpaired.
42    Admit(Pairing),
43    Refuse(AxError),
44}
45
46/// Decides whether one HTTP request may reach the work behind a door.
47///
48/// **The one place an HTTP caller's pairing token is judged.** A door
49/// that judged nothing would let anyone who could reach the port of an
50/// exposed city spend money at a provider.
51///
52/// The rule is the face's, which is why the face is what arrives: a face
53/// that demands no credential is the loopback face of a city nobody
54/// configured a token for, and it came from [`decide_bind`], which
55/// refuses to serve an exposed one. There is nobody to distinguish on
56/// such a city, so every door admits as paired.
57///
58/// [`Door::Acp`] is admitted unpaired on purpose: what an
59/// unauthenticated editor may learn is `agent_protocols::admit`'s to word, and
60/// it words it so that a stranger learns exactly one bit. The other three
61/// doors act, so they refuse here.
62///
63/// [`decide_bind`]: super::decide_bind
64#[must_use]
65pub fn decide_admission(door: Door, offered: Option<&str>, face: &BindFace) -> Admission {
66    let Some(expected) = face.token_digest() else {
67        return Admission::Admit(Pairing::Held);
68    };
69    if auth::verify(offered, expected) {
70        return Admission::Admit(Pairing::Held);
71    }
72    let action = match door {
73        Door::Transcribe => "transcribe a recording",
74        Door::Enroll => "enrol a credential",
75        Door::Drop => "keep a dropped file",
76        Door::Acp => return Admission::Admit(Pairing::Absent),
77    };
78    Admission::Refuse(
79        AxError::failure(
80            AxCode::GateDenied,
81            action,
82            "the request did not carry this city's pairing token",
83        )
84        .with_recovery(
85            "open the control surface from the link that carries the pairing code, or send the \
86             code as an `Authorization: Bearer` header",
87        ),
88    )
89}
90
91/// The one spelling of how an HTTP caller offers the pairing token.
92///
93/// The socket offers it inside the hello frame, where the frame type
94/// names the field; a POST has no frame, so it carries the standard
95/// bearer header and this function is where that scheme is read. A
96/// value that is not a bearer token is no token at all rather than a
97/// token that happens to fail, so a caller cannot learn the scheme by
98/// watching which refusal it gets.
99#[must_use]
100pub fn offered_pairing(header: Option<&str>) -> Option<&str> {
101    let raw = header?.trim();
102    let (scheme, token) = raw.split_once(' ')?;
103    scheme
104        .eq_ignore_ascii_case("Bearer")
105        .then(|| token.trim())
106        .filter(|token| !token.is_empty())
107}
108
109#[cfg(test)]
110#[allow(clippy::panic, reason = "test code")]
111mod tests {
112    use super::*;
113    use kernel::B3Hash;
114
115    /// The face of a city reachable beyond this machine: it demands the
116    /// token by construction.
117    fn exposed() -> BindFace {
118        BindFace::Exposed {
119            token: B3Hash::digest(b"pairing-code"),
120        }
121    }
122
123    /// The face of a city nobody configured a token for, which is only
124    /// ever a city on this machine.
125    fn unpaired() -> BindFace {
126        BindFace::Loopback { token: None }
127    }
128
129    #[test]
130    fn the_acting_doors_refuse_a_stranger_and_the_editor_door_carries_the_bit() {
131        for door in [Door::Transcribe, Door::Enroll, Door::Drop] {
132            let Admission::Refuse(err) = decide_admission(door, None, &exposed()) else {
133                panic!("{door:?} acts on a request, so it refuses an unpaired one");
134            };
135            assert_eq!(*err.code(), AxCode::GateDenied);
136        }
137        assert!(matches!(
138            decide_admission(Door::Acp, None, &exposed()),
139            Admission::Admit(Pairing::Absent)
140        ));
141        assert!(matches!(
142            decide_admission(Door::Transcribe, Some("pairing-code"), &exposed()),
143            Admission::Admit(Pairing::Held)
144        ));
145    }
146
147    #[test]
148    fn a_city_with_no_token_configured_admits_every_door() {
149        for door in [Door::Transcribe, Door::Enroll, Door::Acp, Door::Drop] {
150            assert!(
151                matches!(
152                    decide_admission(door, None, &unpaired()),
153                    Admission::Admit(Pairing::Held)
154                ),
155                "{door:?} on a loopback-only city"
156            );
157        }
158    }
159
160    #[test]
161    fn only_a_bearer_header_offers_a_token() {
162        assert_eq!(offered_pairing(Some("Bearer abc")), Some("abc"));
163        assert_eq!(offered_pairing(Some("bearer  abc ")), Some("abc"));
164        assert_eq!(offered_pairing(Some("Basic abc")), None);
165        assert_eq!(offered_pairing(Some("abc")), None);
166        assert_eq!(offered_pairing(Some("Bearer ")), None);
167        assert_eq!(offered_pairing(None), None);
168    }
169}