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}