Skip to main content

running_process/broker/server/
session_token.rs

1//! Composite broker/daemon session token authority (zackees/soldr#2360, #2361
2//! Phase 1, #2363).
3//!
4//! **STATUS: wired into [`crate::broker::server::hello_handler::HelloHandler`] via
5//! `with_session_token_authority` — opt-in, dormant unless a caller
6//! configures it. See "Not done yet" below for what's still open.**
7//!
8//! ## What this is — a cooperative invalidation signal, NOT authentication
9//!
10//! This is a liveness/generation notification scheme for a cooperative
11//! client inside one trust domain (all roles run as the same user on the
12//! same machine). It is **not** a security boundary and does not
13//! authenticate anyone.
14//!
15//! Every client session carries a composite token `broker_token ‖
16//! daemon_token`: the first half minted once by the broker at its own
17//! startup, the second minted by the specific daemon the client is talking
18//! to. The halves are generation markers — "the broker/daemon incarnation
19//! you established this session against". When a presented token stops
20//! validating, that tells the client its session has terminated or the
21//! broker/daemon got forcefully cycled since its last message: the session
22//! is invalid, and the client should report the error (the
23//! cancelled-because-stopped message class, soldr#2363), unwind, and exit 1
24//! — never retry against the new incarnation as if nothing happened.
25//! Two-level invalidation falls out of the split for free:
26//!
27//! - Rotating the **broker** half signals every session across every
28//!   daemon at once (broker restart, or a live rotation e.g. after the
29//!   spawn-storm guard trips).
30//! - Invalidating one **daemon**'s half signals only that daemon's
31//!   sessions; sessions against other daemons are unaffected.
32//!
33//! The halves come from OS randomness only so that incarnations are
34//! globally unique — a restarted broker or daemon can never accidentally
35//! validate a stale token minted by its predecessor, the way a counter or
36//! timestamp could collide. The bytes are not secrets guarding anything,
37//! which is also why the plain `!=` comparison (mirroring
38//! [`crate::broker::server::handoff::HandoffToken`]) is fine here: constant-time comparison
39//! defends secrets against guessing oracles, and there is no secret and
40//! nothing to guess for.
41//!
42//! There is deliberately **no TTL** on these tokens. A session may go
43//! silent for arbitrarily long (e.g. a link phase with no daemon traffic)
44//! and remain valid; invalidation is communicated lazily, just in time, on
45//! the next communication intent — the client learns its session died at
46//! the exact moment it next tries to use it, which is the only moment it
47//! matters.
48//!
49//! This module is the authority that mints, rotates, and validates both
50//! halves. It deliberately knows nothing about the wire (`Hello.auth_token`,
51//! already reserved on the v2-reuses-v1-framing Hello message — see
52//! `broker_v1_envelope.proto`) or about `HelloHandler` /
53//! `RegisteredBackend` — see "Not done yet".
54//!
55//! ## Not done yet (left for a follow-up slice)
56//!
57//! - `daemon_id` is resolved as `hello.service_name` — the same key
58//!   `RegisteredBackend` already uses — rather than a new field. `Refused`
59//!   uses `ERROR_PEER_REJECTED` for every [`crate::broker::server::session_token::SessionTokenRejection`] kind.
60//!   Both settled in the `HelloHandler` wiring.
61//! - Where the authority instance itself lives (per-broker-process
62//!   singleton state) and how `register_daemon`/`invalidate_daemon` are
63//!   threaded into the daemon spawn/exit lifecycle — that's soldr#2361
64//!   Phase 2 (spawn-chain inversion), which is what will actually call
65//!   `with_session_token_authority` and stop this from being dormant.
66//! - Persistence-boundary invariant from soldr#2363's testing invariants
67//!   ("no token material is ever written under a daemon cache root") —
68//!   this module is pure in-memory today, so that invariant holds trivially
69//!   for it, but the caller that eventually persists broker-side session
70//!   state must uphold it too.
71
72use std::collections::HashMap;
73use std::fmt;
74
75/// Number of bytes in one half of the composite token (128 bits), matching
76/// [`super::handoff::HandoffToken`]'s existing size for consistency.
77pub const SESSION_TOKEN_HALF_BYTES: usize = 16;
78
79/// Total presented-token length: broker half + daemon half.
80pub const SESSION_TOKEN_TOTAL_BYTES: usize = SESSION_TOKEN_HALF_BYTES * 2;
81
82/// One 128-bit half of a composite session token. Used for both the
83/// broker-minted half and each daemon-minted half — the two are typed
84/// identically; which is which is a matter of which map an instance is
85/// looked up in, not a type-level distinction.
86#[derive(Clone, Copy, PartialEq, Eq, Hash)]
87pub struct TokenHalf([u8; SESSION_TOKEN_HALF_BYTES]);
88
89impl TokenHalf {
90    /// Mint one half from operating-system randomness.
91    pub fn generate() -> Result<Self, SessionTokenError> {
92        let mut bytes = [0_u8; SESSION_TOKEN_HALF_BYTES];
93        getrandom::fill(&mut bytes)?;
94        Ok(Self(bytes))
95    }
96
97    /// Build a half from exact bytes (tests; wire decode).
98    pub fn from_bytes(bytes: [u8; SESSION_TOKEN_HALF_BYTES]) -> Self {
99        Self(bytes)
100    }
101
102    /// Borrow the raw bytes, e.g. to concatenate into a presented token.
103    pub fn as_bytes(&self) -> &[u8; SESSION_TOKEN_HALF_BYTES] {
104        &self.0
105    }
106}
107
108impl fmt::Debug for TokenHalf {
109    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
110        f.write_str("TokenHalf(<redacted>)")
111    }
112}
113
114/// Opaque identifier for one daemon's token slot. Deliberately a plain
115/// `String` rather than reusing `ServiceDefinition`'s type, since how a
116/// daemon identity maps to a registered backend is one of the open
117/// wiring questions above — this keeps the authority decoupled from that
118/// decision until it's made.
119pub type DaemonId = String;
120
121/// Why a presented composite token failed validation.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum SessionTokenRejection {
124    /// Presented bytes were not exactly [`SESSION_TOKEN_TOTAL_BYTES`] long.
125    MalformedLength,
126    /// The first half did not match the broker's current token — this is
127    /// the broker-rotation invalidation path: EVERY client sees this
128    /// after a rotation, regardless of which daemon they were talking to.
129    BrokerHalfMismatch,
130    /// The broker half matched, but the daemon named by `daemon_id` has no
131    /// registered token (never registered, or already invalidated).
132    DaemonUnknown,
133    /// Both halves resolved to real tokens, but the daemon half did not
134    /// match — this is the single-daemon invalidation path: only sessions
135    /// naming this `daemon_id` see this.
136    DaemonHalfMismatch,
137}
138
139/// Mints, rotates, and validates the composite `broker_token ‖ daemon_token`
140/// pair described in the module docs.
141#[derive(Debug)]
142pub struct SessionTokenAuthority {
143    broker_token: TokenHalf,
144    daemon_tokens: HashMap<DaemonId, TokenHalf>,
145}
146
147impl SessionTokenAuthority {
148    /// Mint a fresh broker token from OS randomness. Call once at broker
149    /// startup.
150    pub fn new() -> Result<Self, SessionTokenError> {
151        Ok(Self {
152            broker_token: TokenHalf::generate()?,
153            daemon_tokens: HashMap::new(),
154        })
155    }
156
157    /// Test/deterministic constructor — production code should use
158    /// [`Self::new`] so the broker half comes from real randomness.
159    pub fn with_broker_token(broker_token: TokenHalf) -> Self {
160        Self {
161            broker_token,
162            daemon_tokens: HashMap::new(),
163        }
164    }
165
166    /// The current broker-half bytes, for a caller (e.g. the front door
167    /// spawning this broker) to hand to a newly-connecting client alongside
168    /// the daemon half.
169    pub fn broker_token(&self) -> &TokenHalf {
170        &self.broker_token
171    }
172
173    /// Rotate the broker token in place — the live-rotation path (e.g. the
174    /// spawn-storm guard tripping). Every previously-issued composite token
175    /// stops validating immediately: [`Self::validate`] compares against
176    /// the NEW value from the moment this returns.
177    pub fn rotate_broker_token(&mut self) -> Result<TokenHalf, SessionTokenError> {
178        let fresh = TokenHalf::generate()?;
179        self.broker_token = fresh;
180        Ok(fresh)
181    }
182
183    /// Register (or re-register) a daemon's token, minted fresh from OS
184    /// randomness. Call once at that daemon's startup.
185    pub fn register_daemon(&mut self, daemon_id: DaemonId) -> Result<TokenHalf, SessionTokenError> {
186        let token = TokenHalf::generate()?;
187        self.daemon_tokens.insert(daemon_id, token);
188        Ok(token)
189    }
190
191    /// Remove a daemon's token entirely — every session naming this
192    /// `daemon_id` fails [`Self::validate`] with [`SessionTokenRejection::DaemonUnknown`]
193    /// from this call onward. Sessions naming any other `daemon_id` are
194    /// unaffected. Returns whether a token was actually present.
195    pub fn invalidate_daemon(&mut self, daemon_id: &str) -> bool {
196        self.daemon_tokens.remove(daemon_id).is_some()
197    }
198
199    /// How many daemons currently hold a registered token.
200    pub fn daemon_count(&self) -> usize {
201        self.daemon_tokens.len()
202    }
203
204    /// The current composite `broker_token ‖ daemon_token` bytes for
205    /// `daemon_id`, or `None` if it has no registered token (never
206    /// registered, or already invalidated) -- e.g. for a control-channel
207    /// RPC handing the client something to present in a later `Hello`.
208    pub fn composed_token_for(&self, daemon_id: &str) -> Option<Vec<u8>> {
209        let daemon_half = self.daemon_tokens.get(daemon_id)?;
210        Some(compose_presented_token(&self.broker_token, daemon_half))
211    }
212
213    /// Validate a presented composite token against a claimed `daemon_id`
214    /// (which daemon the client's Hello says it wants — see "Not done yet"
215    /// for how that claim reaches here from the wire).
216    ///
217    /// `presented` is `broker_half ‖ daemon_half`, [`SESSION_TOKEN_TOTAL_BYTES`]
218    /// long. Checks the broker half FIRST and independently of daemon
219    /// lookup, so a broker cycle reports as
220    /// [`SessionTokenRejection::BrokerHalfMismatch`] even when the named
221    /// `daemon_id` is also gone — the broker-wide event is the more global
222    /// (and more actionable) verdict, and per-daemon state after a broker
223    /// cycle is stale by definition, so reporting it would mislead the
224    /// client about what happened.
225    pub fn validate(&self, presented: &[u8], daemon_id: &str) -> Result<(), SessionTokenRejection> {
226        if presented.len() != SESSION_TOKEN_TOTAL_BYTES {
227            return Err(SessionTokenRejection::MalformedLength);
228        }
229        let (broker_half, daemon_half) = presented.split_at(SESSION_TOKEN_HALF_BYTES);
230
231        if broker_half != self.broker_token.as_bytes() {
232            return Err(SessionTokenRejection::BrokerHalfMismatch);
233        }
234
235        let Some(expected_daemon_token) = self.daemon_tokens.get(daemon_id) else {
236            return Err(SessionTokenRejection::DaemonUnknown);
237        };
238        if daemon_half != expected_daemon_token.as_bytes() {
239            return Err(SessionTokenRejection::DaemonHalfMismatch);
240        }
241
242        Ok(())
243    }
244}
245
246/// Concatenate a broker half and a daemon half into one presented-token
247/// byte vector, matching what [`SessionTokenAuthority::validate`] expects.
248/// A convenience for a client-side caller assembling `Hello.auth_token`.
249pub fn compose_presented_token(broker_half: &TokenHalf, daemon_half: &TokenHalf) -> Vec<u8> {
250    let mut out = Vec::with_capacity(SESSION_TOKEN_TOTAL_BYTES);
251    out.extend_from_slice(broker_half.as_bytes());
252    out.extend_from_slice(daemon_half.as_bytes());
253    out
254}
255
256/// Errors raised while minting session-token halves.
257#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
258pub enum SessionTokenError {
259    /// Random byte generation failed.
260    #[error("session token random generation failed: {0}")]
261    Random(String),
262}
263
264impl From<getrandom::Error> for SessionTokenError {
265    fn from(value: getrandom::Error) -> Self {
266        Self::Random(value.to_string())
267    }
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    fn half(byte: u8) -> TokenHalf {
275        TokenHalf::from_bytes([byte; SESSION_TOKEN_HALF_BYTES])
276    }
277
278    #[test]
279    fn generate_produces_distinct_halves() {
280        let a = TokenHalf::generate().expect("random");
281        let b = TokenHalf::generate().expect("random");
282        assert_ne!(a.as_bytes(), b.as_bytes(), "two mints must not collide");
283    }
284
285    #[test]
286    fn valid_composite_token_validates() {
287        let mut authority = SessionTokenAuthority::with_broker_token(half(0xAA));
288        let daemon_token = authority.register_daemon("daemon-1".into()).expect("mint");
289        let presented = compose_presented_token(authority.broker_token(), &daemon_token);
290
291        assert_eq!(authority.validate(&presented, "daemon-1"), Ok(()));
292    }
293
294    #[test]
295    fn wrong_broker_half_is_rejected_even_for_a_valid_daemon() {
296        let mut authority = SessionTokenAuthority::with_broker_token(half(0xAA));
297        let daemon_token = authority.register_daemon("daemon-1".into()).expect("mint");
298        let wrong_broker_half = half(0xFF);
299        let presented = compose_presented_token(&wrong_broker_half, &daemon_token);
300
301        assert_eq!(
302            authority.validate(&presented, "daemon-1"),
303            Err(SessionTokenRejection::BrokerHalfMismatch)
304        );
305    }
306
307    #[test]
308    fn broker_half_is_checked_before_daemon_lookup_for_an_unknown_daemon() {
309        // A wrong broker half against a daemon_id that was never
310        // registered must still report BrokerHalfMismatch, not
311        // DaemonUnknown -- after a broker cycle the broker-wide verdict
312        // is the one the client must act on; per-daemon state is stale
313        // by definition and would misattribute what happened.
314        let authority = SessionTokenAuthority::with_broker_token(half(0xAA));
315        let wrong_broker_half = half(0xFF);
316        let daemon_half = half(0x11);
317        let presented = compose_presented_token(&wrong_broker_half, &daemon_half);
318
319        assert_eq!(
320            authority.validate(&presented, "never-registered"),
321            Err(SessionTokenRejection::BrokerHalfMismatch)
322        );
323    }
324
325    #[test]
326    fn correct_broker_half_but_unregistered_daemon_is_rejected() {
327        let authority = SessionTokenAuthority::with_broker_token(half(0xAA));
328        let daemon_half = half(0x11);
329        let presented = compose_presented_token(&half(0xAA), &daemon_half);
330
331        assert_eq!(
332            authority.validate(&presented, "never-registered"),
333            Err(SessionTokenRejection::DaemonUnknown)
334        );
335    }
336
337    #[test]
338    fn wrong_daemon_half_is_rejected() {
339        let mut authority = SessionTokenAuthority::with_broker_token(half(0xAA));
340        authority.register_daemon("daemon-1".into()).expect("mint");
341        let wrong_daemon_half = half(0xEE);
342        let presented = compose_presented_token(&half(0xAA), &wrong_daemon_half);
343
344        assert_eq!(
345            authority.validate(&presented, "daemon-1"),
346            Err(SessionTokenRejection::DaemonHalfMismatch)
347        );
348    }
349
350    #[test]
351    fn malformed_length_is_rejected_before_any_comparison() {
352        let authority = SessionTokenAuthority::with_broker_token(half(0xAA));
353        assert_eq!(
354            authority.validate(&[0xAA; 5], "daemon-1"),
355            Err(SessionTokenRejection::MalformedLength)
356        );
357        assert_eq!(
358            authority.validate(&[], "daemon-1"),
359            Err(SessionTokenRejection::MalformedLength)
360        );
361        assert_eq!(
362            authority.validate(&[0xAA; SESSION_TOKEN_TOTAL_BYTES + 1], "daemon-1"),
363            Err(SessionTokenRejection::MalformedLength)
364        );
365    }
366
367    #[test]
368    fn broker_rotation_invalidates_every_daemons_sessions() {
369        let mut authority = SessionTokenAuthority::with_broker_token(half(0xAA));
370        let daemon_a = authority.register_daemon("daemon-a".into()).expect("mint");
371        let daemon_b = authority.register_daemon("daemon-b".into()).expect("mint");
372        let old_broker_half = *authority.broker_token();
373
374        let presented_a = compose_presented_token(&old_broker_half, &daemon_a);
375        let presented_b = compose_presented_token(&old_broker_half, &daemon_b);
376        assert_eq!(authority.validate(&presented_a, "daemon-a"), Ok(()));
377        assert_eq!(authority.validate(&presented_b, "daemon-b"), Ok(()));
378
379        authority.rotate_broker_token().expect("rotate");
380
381        // Same presented bytes as before -- both daemons' sessions are now
382        // invalid, proving rotation is a broker-wide invalidation, not
383        // scoped to one daemon.
384        assert_eq!(
385            authority.validate(&presented_a, "daemon-a"),
386            Err(SessionTokenRejection::BrokerHalfMismatch)
387        );
388        assert_eq!(
389            authority.validate(&presented_b, "daemon-b"),
390            Err(SessionTokenRejection::BrokerHalfMismatch)
391        );
392    }
393
394    #[test]
395    fn invalidating_one_daemon_does_not_disrupt_another() {
396        let mut authority = SessionTokenAuthority::with_broker_token(half(0xAA));
397        let daemon_a = authority.register_daemon("daemon-a".into()).expect("mint");
398        let daemon_b = authority.register_daemon("daemon-b".into()).expect("mint");
399        let broker_half = *authority.broker_token();
400
401        let presented_a = compose_presented_token(&broker_half, &daemon_a);
402        let presented_b = compose_presented_token(&broker_half, &daemon_b);
403
404        assert!(authority.invalidate_daemon("daemon-a"));
405
406        assert_eq!(
407            authority.validate(&presented_a, "daemon-a"),
408            Err(SessionTokenRejection::DaemonUnknown),
409            "daemon-a's session must be gone"
410        );
411        assert_eq!(
412            authority.validate(&presented_b, "daemon-b"),
413            Ok(()),
414            "daemon-b's session must be completely unaffected"
415        );
416    }
417
418    #[test]
419    fn invalidate_daemon_reports_whether_a_token_was_present() {
420        let mut authority = SessionTokenAuthority::with_broker_token(half(0xAA));
421        assert!(!authority.invalidate_daemon("never-registered"));
422
423        authority.register_daemon("daemon-1".into()).expect("mint");
424        assert!(authority.invalidate_daemon("daemon-1"));
425        // Second call: already gone.
426        assert!(!authority.invalidate_daemon("daemon-1"));
427    }
428
429    #[test]
430    fn re_registering_a_daemon_mints_a_new_token_and_invalidates_the_old_one() {
431        let mut authority = SessionTokenAuthority::with_broker_token(half(0xAA));
432        let broker_half = *authority.broker_token();
433        let first_token = authority.register_daemon("daemon-1".into()).expect("mint");
434        let stale_presented = compose_presented_token(&broker_half, &first_token);
435
436        // Simulates a daemon restarting under the same daemon_id (e.g. the
437        // #2352 version-thrash scenario) and re-registering.
438        let second_token = authority.register_daemon("daemon-1".into()).expect("mint");
439        assert_ne!(first_token.as_bytes(), second_token.as_bytes());
440
441        assert_eq!(
442            authority.validate(&stale_presented, "daemon-1"),
443            Err(SessionTokenRejection::DaemonHalfMismatch),
444            "the pre-restart token must no longer validate"
445        );
446    }
447
448    #[test]
449    fn daemon_count_tracks_registration_and_invalidation() {
450        let mut authority = SessionTokenAuthority::with_broker_token(half(0xAA));
451        assert_eq!(authority.daemon_count(), 0);
452        authority.register_daemon("daemon-1".into()).expect("mint");
453        authority.register_daemon("daemon-2".into()).expect("mint");
454        assert_eq!(authority.daemon_count(), 2);
455        authority.invalidate_daemon("daemon-1");
456        assert_eq!(authority.daemon_count(), 1);
457    }
458
459    #[test]
460    fn debug_impl_redacts_token_bytes() {
461        let token = TokenHalf::from_bytes([0x42; SESSION_TOKEN_HALF_BYTES]);
462        let rendered = format!("{token:?}");
463        assert!(
464            !rendered.contains("42"),
465            "token bytes must not leak into Debug output: {rendered}"
466        );
467        assert!(rendered.contains("redacted"));
468    }
469}