wire/reception.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//! Every judgement the process boundary makes, as pure functions with
7//! exhaustive verdicts.
8//!
9//! Five questions, asked in five different places and answered here:
10//! may this address be bound at all and which face does it then present,
11//! may this peer put a credential in the vault, may this peer's opening
12//! frame be accepted, what does a frame mean in the state the session is
13//! in, and which records must a session tell the peer it lost. None of
14//! them touches a socket, a clock or a file, so all five are tested by
15//! calling them.
16//!
17//! **This is the thick half of the Humble Object** the listener is
18//! built as (ARCHITECTURE section 9). `wire::server` is declared an
19//! adapter — "thin, no policy" — and policy is what these functions
20//! are; a shell that also holds the rules it applies has no way to show
21//! that it applies all of them. Every branch left in the shell is a
22//! send, a receive, or the end of a session.
23//!
24//! Binding is loopback by default, and the face that comes back from
25//! [`decide_bind`] carries the key it demands: a listener refuses to
26//! start without one, loopback included, so no shell can be built with a
27//! face that demands nothing (wire D54).
28
29mod admission;
30mod entry;
31pub(crate) mod inbound;
32mod pairing;
33mod sessions;
34
35pub use admission::{
36 Admission, Door, Keys, Standing, decide_admission, decide_standing, offered_pairing,
37};
38pub use entry::{Arrival, Entry, ListenerOrigins, PageHeaders, Presented, decide_entry};
39pub use pairing::{
40 BrowserDoor, DeviceKey, Guess, LABEL_MAX, OPEN_CODE_LIFETIME_MS, PairedBrowser, decode_hex,
41};
42pub use sessions::Sessions;
43
44use std::net::SocketAddr;
45
46use kernel::{Address, AxCode, AxError, B3Hash, Seq};
47
48use crate::auth;
49use crate::command::WireCommand;
50use crate::frames::{
51 Ask, ClientFrame, Hello, Lagged, Monitoring, WIRE_V, Watched, Welcome, schema_hash,
52};
53
54/// Which face the listener presents, and the key it demands.
55///
56/// **The key travels inside both faces rather than beside them.** A
57/// listener that demands nothing is not a state this type can hold,
58/// loopback included: the loopback port is reachable by every page in a
59/// browser on this machine, by every other account on it and by the
60/// residents' own tools (`crates/wire/spec/Reception/Entry.lean` wire
61/// D54). [`decide_bind`] is the only producer of one.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum BindFace {
64 /// Reachable from this machine only.
65 Loopback { key: B3Hash },
66 /// Reachable from elsewhere.
67 Exposed { key: B3Hash },
68}
69
70impl BindFace {
71 /// The digest of the key this face demands of every caller that holds
72 /// no session.
73 #[must_use]
74 pub fn key(&self) -> &B3Hash {
75 match self {
76 BindFace::Loopback { key } | BindFace::Exposed { key } => key,
77 }
78 }
79}
80
81/// The whole of the binding policy.
82#[derive(Debug)]
83pub enum BindVerdict {
84 Serve(BindFace),
85 Refuse(AxError),
86}
87
88/// Decides whether the listener may bind `addr`, and which face it then
89/// presents.
90///
91/// Pure. Refuses exactly when no key is given, on either face, before
92/// the socket exists - so there is no window in which the port is open
93/// and unauthenticated - and the key the face demands comes back inside
94/// the verdict, so there is no second way to learn it.
95#[must_use]
96pub fn decide_bind(addr: &SocketAddr, key: Option<B3Hash>) -> BindVerdict {
97 match (key, addr.ip().is_loopback()) {
98 (Some(key), true) => BindVerdict::Serve(BindFace::Loopback { key }),
99 (Some(key), false) => BindVerdict::Serve(BindFace::Exposed { key }),
100 (None, _) => BindVerdict::Refuse(
101 AxError::failure(
102 AxCode::ConfigInvalid,
103 "bind the control surface",
104 format!("{addr} was given no key to demand of its callers"),
105 )
106 .with_recovery("configure a pairing token, or let the host mint a key for this serve"),
107 ),
108 }
109}
110/// Whether one peer may enrol a credential.
111#[derive(Debug)]
112pub enum EnrollVerdict {
113 Accept,
114 Refuse(AxError),
115}
116
117/// Decides whether `peer` may put plaintext into this machine's vault.
118///
119/// Pure, and the whole of the policy: only a caller on this machine may.
120/// A pairing token is not enough, because a token authenticates a person
121/// and this rule is about where the bytes travel. The design's guarantee
122/// is that a credential cannot be enrolled remotely at all - the socket
123/// half is type-level (`PutSecret` has no wire form), and this is the
124/// HTTP half, which needs a runtime check because bytes can always be
125/// posted at a route.
126#[must_use]
127pub fn decide_enroll(peer: &SocketAddr) -> EnrollVerdict {
128 // A `[::]` listener on Linux and macOS reports a local IPv4 caller as
129 // `::ffff:127.0.0.1`, which is the same machine.
130 if peer.ip().to_canonical().is_loopback() {
131 return EnrollVerdict::Accept;
132 }
133 EnrollVerdict::Refuse(
134 AxError::failure(
135 AxCode::GateDenied,
136 "enrol a credential",
137 format!("{peer} is not on this machine"),
138 )
139 .with_recovery(
140 "enrol the credential from the machine running sprawling; a tunnelled session can \
141 use it afterwards but cannot deliver it",
142 ),
143 )
144}
145
146/// The verdict on one peer's opening frame.
147#[derive(Debug)]
148pub enum HandshakeVerdict {
149 Accept,
150 Reject(AxError),
151}
152
153/// Decides whether to accept a peer.
154///
155/// Order is deliberate: protocol agreement is settled before credentials.
156/// A browser holding a cached older client is the common case and deserves
157/// "refresh", not "wrong password". Pure - `expected` and `keys` are
158/// parameters, never read from ambient state.
159///
160/// The credential a caller must present is the face's key or a live
161/// session token ([`Keys::pairing`]); neither face can be served without
162/// a key, so there is no greeting this accepts empty-handed.
163///
164/// The digest is a digest, not the token. This crate never holds the
165/// plaintext of a pairing token: the side that owns the token digests it
166/// once, and the boundary compares digests. That keeps credential exposure
167/// at the redemption points where it is audited, and it costs nothing here
168/// because the comparison hashes both sides anyway.
169#[must_use]
170pub fn decide_handshake(hello: &Hello, expected: &Welcome, keys: &Keys<'_>) -> HandshakeVerdict {
171 if hello.wire_v != expected.wire_v || hello.schema != expected.schema {
172 return HandshakeVerdict::Reject(
173 AxError::failure(
174 AxCode::WireMismatch,
175 "accept a client connection",
176 format!(
177 "client speaks wire v{} and this server speaks v{}",
178 hello.wire_v, expected.wire_v
179 ),
180 )
181 .with_recovery("reload the page to fetch the client this server was built with"),
182 );
183 }
184 match keys.pairing(hello.token.as_deref()) {
185 auth::Pairing::Held => HandshakeVerdict::Accept,
186 auth::Pairing::Absent => HandshakeVerdict::Reject(
187 AxError::failure(
188 AxCode::ConfigInvalid,
189 "accept a client connection",
190 "the greeting carries neither this city's key nor a live session token",
191 )
192 .with_recovery(
193 "reload the page to sign in again; a program on this machine reads the key \
194 from the city's key file, or takes --token",
195 ),
196 ),
197 }
198}
199/// How far a socket session has got. Two states, because there are two:
200/// a peer that has not identified itself, and one that has.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum SessionState {
203 AwaitingHello,
204 Live,
205}
206
207/// What the shell does with one client frame. Exhaustive: a new frame
208/// kind has to be answered here rather than falling through to silence.
209#[derive(Debug)]
210pub enum SessionStep {
211 /// Answer with this welcome and move to [`SessionState::Live`].
212 Welcome(Box<Welcome>),
213 /// Hand this command to the sink.
214 Deliver(Box<WireCommand>),
215 /// Evaluate this question and answer it under its own number.
216 Answer(Box<Ask>),
217 /// Count this session as watching the monitor and send it readings.
218 Watch(Watched),
219 /// Stop counting this session and stop sending it readings.
220 Release,
221 /// Sample the monitor at this beat, for every session of this city.
222 Beat(crate::BeatMs),
223 /// Send this refusal; `close` ends the session afterwards.
224 Refuse { error: Box<AxError>, close: bool },
225}
226
227/// What a welcome tells a peer about the city it reached: which city it
228/// is, the seq of the last record the city has broadcast, and which
229/// ledger it is. One value
230/// because the two travel together into every welcome.
231#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
232pub struct WelcomeFacts<'a> {
233 pub city: Option<&'a Address>,
234 pub head: Option<Seq>,
235 pub epoch: Option<B3Hash>,
236}
237
238/// The whole session policy, as a pure function: which frames are legal
239/// when, and what a mismatch does. Tested without a socket, for the same
240/// reason [`decide_bind`] is.
241///
242/// A command that arrives before the hello is refused rather than queued:
243/// the peer has not yet shown that it speaks this wire, and running work
244/// for it would be trusting a stranger's first sentence.
245#[must_use]
246pub fn decide_frame(
247 state: SessionState,
248 frame: ClientFrame,
249 keys: &Keys<'_>,
250 standing: WelcomeFacts<'_>,
251) -> SessionStep {
252 let expected = Welcome {
253 wire_v: WIRE_V,
254 schema: schema_hash(),
255 resume_from: standing.head,
256 city: standing.city.cloned(),
257 epoch: standing.epoch,
258 };
259 match (state, frame) {
260 (SessionState::AwaitingHello, ClientFrame::Hello(hello)) => {
261 match decide_handshake(&hello, &expected, keys) {
262 HandshakeVerdict::Accept => SessionStep::Welcome(Box::new(expected)),
263 HandshakeVerdict::Reject(error) => SessionStep::Refuse {
264 error: Box::new(error),
265 close: true,
266 },
267 }
268 }
269 (SessionState::AwaitingHello, _) => SessionStep::Refuse {
270 error: Box::new(
271 AxError::failure(
272 AxCode::WireMismatch,
273 "accept a client frame",
274 "the session has not been opened with a hello",
275 )
276 .with_recovery("send hello first; reload the page if the client did not"),
277 ),
278 close: true,
279 },
280 (SessionState::Live, ClientFrame::Command(command)) => SessionStep::Deliver(command),
281 (SessionState::Live, ClientFrame::Ask(ask)) => SessionStep::Answer(Box::new(ask)),
282 (SessionState::Live, ClientFrame::Monitor(Monitoring::Watch)) => {
283 SessionStep::Watch(Watched::Everything)
284 }
285 (SessionState::Live, ClientFrame::Monitor(Monitoring::WatchSummary)) => {
286 SessionStep::Watch(Watched::Summary)
287 }
288 (SessionState::Live, ClientFrame::Monitor(Monitoring::Release)) => SessionStep::Release,
289 (SessionState::Live, ClientFrame::Monitor(Monitoring::Beat(beat))) => {
290 SessionStep::Beat(beat)
291 }
292 (SessionState::Live, ClientFrame::Hello(_)) => SessionStep::Refuse {
293 error: Box::new(
294 AxError::failure(
295 AxCode::WireMismatch,
296 "accept a client frame",
297 "this session is already open",
298 )
299 .with_recovery("open a second connection instead of re-greeting on this one"),
300 ),
301 close: false,
302 },
303 }
304}
305
306/// The range of ledger records a session must name when its event stream
307/// skipped some.
308///
309/// `delivered` is the last record this session sent the peer, or `None`
310/// when it has sent none; `next` is the first record to arrive after the
311/// gap. Pure, and the whole of the rule. The count a lagged subscription
312/// reports says how many messages were skipped and neither endpoint, which
313/// is why both ends are derived from records this session can name - and
314/// why the far end is only knowable here, at the record that ends the gap.
315///
316/// `None` when there is no gap to state: nothing was skipped before
317/// `next`, which happens when `next` is the record `delivered` already
318/// named or when it is the first record there is. A sequence number that
319/// cannot be stepped returns `None` for the same reason and is a case a
320/// ledger which produced `next` cannot reach.
321#[must_use]
322pub(crate) fn decide_lag(delivered: Option<Seq>, next: Seq) -> Option<Lagged> {
323 let from = match delivered {
324 Some(last) => last.value().checked_add(1).map(Seq::new),
325 // A session that has delivered nothing has shown the peer no
326 // stream, so the gap relative to it starts at the first record
327 // the city ever wrote.
328 None => Some(Seq::FIRST),
329 };
330 let to = next.value().checked_sub(1).map(Seq::new);
331 match (from, to) {
332 (Some(from), Some(to)) if to >= from => Some(Lagged { from, to }),
333 _ => None,
334 }
335}
336
337/// What a session owes the peer about the event stream.
338///
339/// Two facts that belong together: the last record this session delivered,
340/// and whether the stream skipped past it since. They travel as one value
341/// because every other combination is meaningless - a session that owes a
342/// range has a delivery to name the near end of it, and one that owes none
343/// has said everything it holds. The rule lives here rather than in the
344/// shell for the reason every other judgement does: it is testable without
345/// a socket.
346#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub(crate) enum Stream {
348 /// The peer holds every record this session sent it.
349 Even(Option<Seq>),
350 /// The stream skipped records, and the next one to arrive names the far
351 /// end of what was skipped. The payload is the last delivery.
352 Owed(Option<Seq>),
353}
354
355impl Stream {
356 /// A session that has delivered nothing and owes nothing, which is how
357 /// every session starts.
358 pub(crate) const fn opening() -> Stream {
359 Stream::Even(None)
360 }
361
362 /// The record this session last delivered, from either state.
363 #[must_use]
364 pub(crate) fn delivered(self) -> Option<Seq> {
365 match self {
366 Stream::Even(delivered) | Stream::Owed(delivered) => delivered,
367 }
368 }
369
370 /// The state a skip leaves behind. A session that has not been
371 /// welcomed has shown the peer no stream to measure a gap against - its
372 /// view begins at the welcome, and what a page needs of what came
373 /// before is a question rather than a frame - so it owes nothing.
374 #[must_use]
375 pub(crate) fn skipped(self, live: bool) -> Stream {
376 if live {
377 Stream::Owed(self.delivered())
378 } else {
379 self
380 }
381 }
382
383 /// What to say before sending `next`, and the state that sending it
384 /// leaves behind. The far end of a skipped range is only knowable at
385 /// the record that ends it.
386 #[must_use]
387 pub(crate) fn before(self, next: Seq) -> (Option<Lagged>, Stream) {
388 let lag = match self {
389 // A record that never reached the broadcast leaves only a gap
390 // in `seq`; before anything was delivered there is no gap.
391 Stream::Even(None) => None,
392 Stream::Even(delivered @ Some(_)) | Stream::Owed(delivered) => {
393 decide_lag(delivered, next)
394 }
395 };
396 (lag, Stream::Even(Some(next)))
397 }
398}
399#[cfg(test)]
400#[allow(
401 clippy::unwrap_used,
402 clippy::expect_used,
403 clippy::panic,
404 clippy::indexing_slicing,
405 reason = "test code"
406)]
407mod tests;