use std::sync::atomic::{AtomicU64, Ordering};
use getrandom;
use thiserror::Error;
use crate::application::session::IdSource;
use crate::domain::session::SessionId;
#[derive(Debug, Error)]
pub enum EntropyError {
#[error("the operating system entropy source is unavailable: {source}")]
Unavailable {
#[source]
source: getrandom::Error,
},
}
#[derive(Debug)]
pub struct RandomIds {
fallback: AtomicU64,
}
impl RandomIds {
pub fn new() -> Result<Self, EntropyError> {
Ok(Self {
fallback: AtomicU64::new(draw()?),
})
}
}
impl IdSource for RandomIds {
fn next_session_id(&self) -> SessionId {
let value = draw().unwrap_or_else(|_| self.fallback.fetch_add(1, Ordering::Relaxed));
SessionId::new(value)
}
}
#[must_use]
pub fn random_state_key() -> Option<crate::application::mcp::input_required::StateKey> {
let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes).ok()?;
Some(crate::application::mcp::input_required::StateKey::from_bytes(bytes))
}
fn draw() -> Result<u64, EntropyError> {
let mut bytes = [0_u8; 8];
getrandom::fill(&mut bytes).map_err(|source| EntropyError::Unavailable { source })?;
Ok(u64::from_ne_bytes(bytes))
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::sync::atomic::Ordering;
use super::{EntropyError, RandomIds};
use crate::application::session::IdSource;
#[test]
fn consecutive_ids_are_not_consecutive() -> Result<(), EntropyError> {
let ids = RandomIds::new()?;
let first = ids.next_session_id().get();
let second = ids.next_session_id().get();
assert_ne!(first, second, "two draws must not be equal");
assert_ne!(
second.wrapping_sub(first),
1,
"consecutive draws differ by one, which is a counter"
);
Ok(())
}
#[test]
fn many_draws_do_not_repeat() -> Result<(), EntropyError> {
const DRAWS: u64 = 10_000;
let ids = RandomIds::new()?;
let mut seen = HashSet::new();
for index in 0..DRAWS {
let id = ids.next_session_id();
assert!(
seen.insert(id),
"draw {index} repeated an id already issued by this source"
);
}
Ok(())
}
#[test]
fn independent_sources_disagree() -> Result<(), EntropyError> {
let first = RandomIds::new()?;
let second = RandomIds::new()?;
assert_ne!(
first.next_session_id(),
second.next_session_id(),
"two sources drawn from the same entropy pool started identically"
);
Ok(())
}
#[test]
fn the_fallback_starts_from_the_constructor_seed() -> Result<(), EntropyError> {
let ids = RandomIds::new()?;
let seeded = ids.fallback.load(Ordering::Relaxed);
let first_degraded = ids.next_session_id().get();
if first_degraded == seeded {
assert_ne!(
seeded, 0,
"a failed draw fell back to zero rather than the constructor seed"
);
}
Ok(())
}
#[test]
fn getrandom_fills_every_byte_and_is_not_a_counter() -> Result<(), EntropyError> {
const SAMPLE: usize = 256;
let mut draws: Vec<u64> = Vec::with_capacity(SAMPLE);
for _ in 0..SAMPLE {
let mut bytes = [0_u8; 8];
getrandom::fill(&mut bytes).map_err(|source| EntropyError::Unavailable { source })?;
draws.push(u64::from_ne_bytes(bytes));
}
let unique: HashSet<u64> = draws.iter().copied().collect();
assert_eq!(
unique.len(),
SAMPLE,
"two of {SAMPLE} draws collided — the source is no longer CSPRNG-graded"
);
Ok(())
}
}