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}