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}