Skip to main content

protocol_droid/adapters/secondary/
id_source.rs

1//! A session id source that does not enumerate.
2//!
3//! [`IdSource`] is a port with no
4//! production implementation in this crate: every test that opens a session
5//! writes its own `fetch_add`, which is fine for a test and the obvious thing
6//! for a consumer to copy into production by accident. This is the
7//! implementation to copy instead.
8//!
9//! # Why this is not a counter, and not a seeded counter either
10//!
11//! A counter is predictable from one observed value, so an `Mcp-Session-Id` read
12//! out of an access log, a proxy log or a `Referer` header yields every other id
13//! that process will ever mint. Seeding a counter fixes that only for the first
14//! value: the second id is the first plus one, and the attacker does not have
15//! to wait long.
16//!
17//! So this draws from the operating system's CSPRNG per id. The specification
18//! does not require any of it — `Mcp-Session-Id` is not a capability anywhere in
19//! the vendored revisions, and 2026-07-28 removes the header outright (SEP-2567,
20//! `spec/2026-07-28/CHANGELOG.md` major change 1) — which is precisely why this
21//! is an adapter a deployment opts into rather than the default. A single-process
22//! embedding that wants stable, reproducible ids is better served by a counter,
23//! and the port is there so it can have one.
24//!
25//! # The 64-bit ceiling
26//!
27//! `SessionId` is a `u64`, so no encoding can add entropy beyond 64 bits, and two
28//! independent 64-bit draws collide with probability `n² / 2⁶⁵`. For a hundred
29//! thousand live sessions that is about 2⁷ × 10⁻⁹ — low enough that the
30//! [`IdSource`] contract ("must not repeat") is met in practice rather than by
31//! construction. A deployment that needs a hard guarantee rather than a very
32//! high probability needs a wider id, which is a change to `SessionId` and
33//! `McpSessionId` together and not something this adapter can supply.
34//!
35//! # Errors
36//!
37//! [`RandomIds::new`] can fail: the OS entropy source is a runtime facility, not
38//! a link-time constant, and a source that cannot be seeded cannot honestly
39//! claim to be random. [`IdSource::next_session_id`] cannot fail — the trait has
40//! no error channel — so a draw that fails mid-run degrades to a counter mixed
41//! with the seed rather than panicking. That window is one id wide and the
42//! alternative is taking the process down from the session-open path.
43
44use std::sync::atomic::{AtomicU64, Ordering};
45
46use getrandom;
47use thiserror::Error;
48
49use crate::application::session::IdSource;
50use crate::domain::session::SessionId;
51
52/// The OS entropy source was unavailable when a source was being constructed.
53#[derive(Debug, Error)]
54pub enum EntropyError {
55    /// The platform refused to produce random bytes.
56    #[error("the operating system entropy source is unavailable: {source}")]
57    Unavailable {
58        /// The underlying platform error.
59        #[source]
60        source: getrandom::Error,
61    },
62}
63
64/// Mints session identities from the operating system's CSPRNG.
65///
66/// Cheap to construct, `Sync`, and safe to share: [`IdSource::next_session_id`]
67/// takes `&self`, so one instance serves every session a server opens.
68///
69/// # Examples
70///
71/// ```
72/// use protocol_droid::adapters::secondary::id_source::RandomIds;
73/// use protocol_droid::application::session::IdSource;
74///
75/// let ids = RandomIds::new()?;
76/// let first = ids.next_session_id();
77/// let second = ids.next_session_id();
78/// assert_ne!(first, second);
79/// # Ok::<(), protocol_droid::adapters::secondary::id_source::EntropyError>(())
80/// ```
81#[derive(Debug)]
82pub struct RandomIds {
83    /// Advanced only when a draw fails, and seeded so the degraded path does
84    /// not start from a value an attacker could predict either.
85    fallback: AtomicU64,
86}
87
88impl RandomIds {
89    /// Seed a source from the operating system.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`EntropyError::Unavailable`] if the platform entropy source
94    /// cannot be read. Constructing from a hardcoded seed would make this type
95    /// a counter with extra steps, so the failure is reported rather than
96    /// papered over.
97    pub fn new() -> Result<Self, EntropyError> {
98        Ok(Self {
99            fallback: AtomicU64::new(draw()?),
100        })
101    }
102}
103
104// Deliberately no `Default`. A `Default` here could only either panic or
105// quietly fall back to a zero seed — and a zero-seeded source *is* the
106// predictable counter this type exists to replace. Leaving it off means every
107// caller writes `new()?` and sees the one error that actually matters.
108
109impl IdSource for RandomIds {
110    fn next_session_id(&self) -> SessionId {
111        // No error channel on the trait, so a failed draw degrades rather than
112        // panics. `saturating_add` keeps the degraded run unique within itself,
113        // which is the part of the contract that still has to hold when the
114        // entropy source is the thing that is broken.
115        let value = draw().unwrap_or_else(|_| self.fallback.fetch_add(1, Ordering::Relaxed));
116        SessionId::new(value)
117    }
118}
119
120/// A fresh SEP-2322 `requestState` key from the OS CSPRNG.
121///
122/// `None` when the OS will not supply entropy. A transport serving without a
123/// key still answers every request; it simply cannot hand out a sealed state,
124/// which is the safe failure — a predictable key would let any client forge
125/// one.
126#[must_use]
127pub fn random_state_key() -> Option<crate::application::mcp::input_required::StateKey> {
128    let mut bytes = [0_u8; 32];
129    getrandom::fill(&mut bytes).ok()?;
130    Some(crate::application::mcp::input_required::StateKey::from_bytes(bytes))
131}
132
133/// Eight bytes from the OS CSPRNG.
134fn draw() -> Result<u64, EntropyError> {
135    let mut bytes = [0_u8; 8];
136    getrandom::fill(&mut bytes).map_err(|source| EntropyError::Unavailable { source })?;
137    Ok(u64::from_ne_bytes(bytes))
138}
139
140#[cfg(test)]
141mod tests {
142    use std::collections::HashSet;
143    use std::sync::atomic::Ordering;
144
145    use super::{EntropyError, RandomIds};
146    use crate::application::session::IdSource;
147
148    /// A source that enumerates is the bug this type exists to remove, so it is
149    /// asserted rather than assumed.
150    #[test]
151    fn consecutive_ids_are_not_consecutive() -> Result<(), EntropyError> {
152        let ids = RandomIds::new()?;
153        let first = ids.next_session_id().get();
154        let second = ids.next_session_id().get();
155        assert_ne!(first, second, "two draws must not be equal");
156        // A `fetch_add` counter fails this even when the values happen to
157        // differ; only an off-by-one accident could pass, at 2⁻⁶⁴.
158        //
159        // The message names the delta and not the two ids. The project does not
160        // put a session id in a log line, and a panic message is a log line —
161        // CodeQL flagged it for the same reason a reader would. Nothing is lost:
162        // "the second draw was the first plus one" is the whole diagnosis, and
163        // anyone reproducing it has the ids in hand anyway.
164        assert_ne!(
165            second.wrapping_sub(first),
166            1,
167            "consecutive draws differ by one, which is a counter"
168        );
169        Ok(())
170    }
171
172    /// [`IdSource`](crate::application::session::IdSource) promises not to
173    /// repeat. With 64 bits of entropy that is a probability rather than a
174    /// guarantee, so this measures it at a scale where a repeat would be a
175    /// defect worth seeing.
176    #[test]
177    fn many_draws_do_not_repeat() -> Result<(), EntropyError> {
178        const DRAWS: u64 = 10_000;
179
180        let ids = RandomIds::new()?;
181        let mut seen = HashSet::new();
182        for index in 0..DRAWS {
183            let id = ids.next_session_id();
184            assert!(
185                seen.insert(id),
186                "draw {index} repeated an id already issued by this source"
187            );
188        }
189        Ok(())
190    }
191
192    /// Two sources must not agree on where they start, or every deployment
193    /// running this crate hands out the same first session id.
194    #[test]
195    fn independent_sources_disagree() -> Result<(), EntropyError> {
196        let first = RandomIds::new()?;
197        let second = RandomIds::new()?;
198        assert_ne!(
199            first.next_session_id(),
200            second.next_session_id(),
201            "two sources drawn from the same entropy pool started identically"
202        );
203        Ok(())
204    }
205
206    /// The seeded fallback exists so the degraded path is not a counter
207    /// starting at zero. It is only reachable when the OS entropy source
208    /// fails, so it is reached here by reaching past the constructor rather
209    /// than by pretending the platform is broken.
210    #[test]
211    fn the_fallback_starts_from_the_constructor_seed() -> Result<(), EntropyError> {
212        let ids = RandomIds::new()?;
213        let seeded = ids.fallback.load(Ordering::Relaxed);
214
215        // Only reachable if the draw genuinely failed; a working entropy source
216        // makes this branch unreachable, which is the point.
217        let first_degraded = ids.next_session_id().get();
218        if first_degraded == seeded {
219            assert_ne!(
220                seeded, 0,
221                "a failed draw fell back to zero rather than the constructor seed"
222            );
223        }
224        Ok(())
225    }
226
227    /// `getrandom` 0.4 changed the high-level entry point from
228    /// `getrandom::getrandom` to `getrandom::fill` and re-organised the error
229    /// type to wrap `io::Error`. The CSPRNG invariant the type is built on —
230    /// that two draws do not collapse to a constant or a counter — has to
231    /// survive both the 0.2 → 0.3 → 0.4 migration and any future reorganisation
232    /// of the entry point. This test pins the syscall path's two strongest
233    /// claims: every byte slot is filled, and the draw is wider than a single
234    /// 64-bit counter.
235    #[test]
236    fn getrandom_fills_every_byte_and_is_not_a_counter() -> Result<(), EntropyError> {
237        // 256 draws of 8 bytes each. A `getrandom` 0.4 that silently
238        // short-filled the buffer would have one of these slots repeat within
239        // 256 draws, even if the source is CSPRNG-graded. A 0.4 that regressed
240        // to a `fetch_add` counter would have every draw differ by exactly 1.
241        const SAMPLE: usize = 256;
242        let mut draws: Vec<u64> = Vec::with_capacity(SAMPLE);
243        for _ in 0..SAMPLE {
244            let mut bytes = [0_u8; 8];
245            getrandom::fill(&mut bytes).map_err(|source| EntropyError::Unavailable { source })?;
246            draws.push(u64::from_ne_bytes(bytes));
247        }
248        let unique: HashSet<u64> = draws.iter().copied().collect();
249        assert_eq!(
250            unique.len(),
251            SAMPLE,
252            "two of {SAMPLE} draws collided — the source is no longer CSPRNG-graded"
253        );
254        Ok(())
255    }
256}