Skip to main content

arkhe_rand/
lib.rs

1//! BLAKE3-keyed PRNG with `split()` determinism — Lemire unbiased range +
2//! Fisher-Yates shuffle. Shell-side use (kernel/runtime forbids RNG for
3//! deterministic replay).
4//!
5//! # Layer scope
6//!
7//! `arkhe-rand` is an **L3 Library** tier crate per the ArkheForge layer
8//! model (L0 Kernel / L1 Runtime Primitives / L2 Runtime Services /
9//! L3 Library / L4-L6 Shell). The kernel and forge runtime forbid RNG
10//! entirely to preserve deterministic WAL replay; this crate is consumed
11//! only by shell-side code (BBS, examples, downstream applications).
12//!
13//! # Cryptographic core
14//!
15//! Each [`RngSource`] wraps a BLAKE3 XOF stream constructed via the KDF
16//! mode `Hasher::new_derive_key("arkhe-rand stream v0.13").update(seed)`.
17//! The context string eliminates cross-domain seed collisions; the
18//! `v0.13` tag is permanent under the project's single-version pin so
19//! patch releases (0.13.x) preserve wire stability for stored seeds.
20//!
21//! XOF reader monotonic property is inherited from the `blake3` crate
22//! spec (audited via `supply-chain/audits.toml [[audits.blake3]]`).
23//!
24//! # API
25//!
26//! - [`RngSource::from_seed`] / [`RngSource::from_os_entropy`] /
27//!   [`RngSource::split`] / [`RngSource::fill_bytes`]
28//! - [`gen_range`] / [`gen_range_inclusive`] (Lemire `nearlydivisionless`)
29//! - [`shuffle`] (Fisher-Yates, in-place)
30//!
31//! # Cross-platform determinism
32//!
33//! Byte-to-integer conversions use explicit little-endian
34//! (`u32::from_le_bytes` / `u64::from_le_bytes`) regardless of host
35//! endianness, so x86_64 / aarch64 / wasm32 produce byte-identical
36//! streams from the same seed. CI enforces this via the golden-vector
37//! cross-compile comparison plus a repository self-grep that forbids
38//! native-endian conversion helpers in the source tree.
39
40#![no_std]
41#![forbid(unsafe_code)]
42#![warn(missing_docs)]
43
44use core::fmt;
45
46use zeroize::Zeroizing;
47
48mod range;
49mod shuffle;
50
51pub use range::{gen_range, gen_range_inclusive, RandInt};
52pub use shuffle::shuffle;
53
54/// BLAKE3 KDF context string. Permanent under the v0.13 single-version
55/// pin — patch releases (0.13.x) keep this exact byte sequence so
56/// stored seeds replay byte-identically.
57const KDF_CONTEXT: &str = "arkhe-rand stream v0.13";
58
59/// BLAKE3-keyed PRNG.
60///
61/// `RngSource` consumes 32 bytes of seed material (deterministic mode
62/// via [`from_seed`]) or OS entropy (`os-entropy` feature, [`from_os_entropy`])
63/// and produces a monotonic byte stream via BLAKE3's eXtendable Output
64/// Function.
65///
66/// # Drop semantics
67///
68/// On drop, `seed` is zeroized via `Zeroizing<[u8; 32]>`. The internal
69/// XOF state is replaced with a sentinel zero-keyed reader; the
70/// discarded reader drops normally — allocator-dependent behavior, not
71/// internal-state wipe (blake3 does not expose that surface).
72/// Best-effort defense-in-depth.
73///
74/// # Debug redaction
75///
76/// `Debug` prints `RngSource { .. }` only — seed bytes and XOF state
77/// are never exposed.
78///
79/// [`from_seed`]: RngSource::from_seed
80/// [`from_os_entropy`]: RngSource::from_os_entropy
81pub struct RngSource {
82    // `seed` is held purely as a drop-zeroize guard via `Zeroizing<T>` —
83    // the bytes are consumed only at construction time inside
84    // `from_seed` and never re-read by the API. The compiler flags it
85    // as dead, but removing it would lose the guarantee that the seed
86    // material is overwritten when the `RngSource` is dropped.
87    #[allow(dead_code)]
88    seed: Zeroizing<[u8; 32]>,
89    xof: blake3::OutputReader,
90}
91
92impl RngSource {
93    /// Construct a deterministic `RngSource` from a 32-byte seed.
94    ///
95    /// Stream is produced via BLAKE3 KDF mode:
96    /// `Hasher::new_derive_key(KDF_CONTEXT).update(seed).finalize_xof()`.
97    /// Two `RngSource` instances built from the same seed produce
98    /// byte-identical streams across all targets.
99    ///
100    /// Callers holding seed material in a non-`Zeroizing` buffer
101    /// should wrap it themselves — this constructor only zeroizes the
102    /// internal copy, not the caller's source bytes.
103    pub fn from_seed(seed: &[u8; 32]) -> Self {
104        let mut hasher = blake3::Hasher::new_derive_key(KDF_CONTEXT);
105        hasher.update(seed.as_slice());
106        let xof = hasher.finalize_xof();
107        Self {
108            seed: Zeroizing::new(*seed),
109            xof,
110        }
111    }
112
113    /// Construct an `RngSource` from OS entropy (`getrandom`).
114    ///
115    /// Returns `Err(RngError::OsEntropyUnavailable)` when the OS
116    /// CSPRNG is unreachable (kernel pre-init / WASM without crypto
117    /// interface). Never panics.
118    #[cfg(feature = "os-entropy")]
119    pub fn from_os_entropy() -> Result<Self, RngError> {
120        let mut seed = Zeroizing::new([0u8; 32]);
121        getrandom::getrandom(seed.as_mut_slice()).map_err(RngError::OsEntropyUnavailable)?;
122        Ok(Self::from_seed(&seed))
123    }
124
125    /// Derive an independent child `RngSource` from this stream.
126    ///
127    /// 32 bytes are consumed from the parent XOF stream and used as
128    /// the child seed. Parent and child streams are independent —
129    /// consuming one does not advance the other, and both remain
130    /// deterministic given the original seed. Typical use: server /
131    /// table / hand 3-level split for multiplayer deal isolation.
132    pub fn split(&mut self) -> Self {
133        // Local child seed is `Zeroizing`-wrapped to close the
134        // stack-leak window between XOF read and `from_seed` copy.
135        let mut child = Zeroizing::new([0u8; 32]);
136        self.xof.fill(child.as_mut_slice());
137        Self::from_seed(&child)
138    }
139
140    /// Fill `buf` with `buf.len()` bytes from the XOF stream.
141    ///
142    /// Stream advance is monotonic — each call advances exactly
143    /// `buf.len()` bytes (entropy accounting). Timing is bounded by
144    /// `buf.len()` only; no input-dependent timing leak.
145    pub fn fill_bytes(&mut self, buf: &mut [u8]) {
146        self.xof.fill(buf);
147    }
148}
149
150impl Drop for RngSource {
151    fn drop(&mut self) {
152        // `seed` auto-zeroized by Zeroizing<T>::drop. XOF state:
153        // best-effort sentinel replacement; the discarded reader
154        // drops normally (blake3 does not expose internal state
155        // wipe). See the type-level docs.
156        let zero_seed = [0u8; 32];
157        let mut sentinel = blake3::Hasher::new_derive_key(KDF_CONTEXT);
158        sentinel.update(&zero_seed);
159        self.xof = sentinel.finalize_xof();
160    }
161}
162
163impl fmt::Debug for RngSource {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        f.debug_struct("RngSource").finish_non_exhaustive()
166    }
167}
168
169/// Error returned by [`RngSource::from_os_entropy`].
170#[cfg(feature = "os-entropy")]
171#[non_exhaustive]
172#[derive(Debug)]
173pub enum RngError {
174    /// OS CSPRNG unreachable. The wrapped `getrandom::Error` carries
175    /// the platform-specific cause.
176    OsEntropyUnavailable(getrandom::Error),
177}
178
179#[cfg(feature = "os-entropy")]
180impl fmt::Display for RngError {
181    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
182        match self {
183            Self::OsEntropyUnavailable(e) => write!(f, "OS entropy unavailable: {e}"),
184        }
185    }
186}