vitaminc_random/safe_rand.rs
1//! A secure random number generator that is safe to use for cryptographic purposes.
2//! It is intentionally opinionated so that developers don't have to think about what Rng they should use
3//! for cryptographic purposes.
4//!
5//! Internally it uses `ChaCha20Rng` from the RustCrypto `chacha20` crate (via `rand`), built with
6//! its `zeroize` feature so the generator's key schedule and buffered keystream are wiped on drop.
7use std::convert::Infallible;
8
9use rand::{rngs::SysRng, Rng, SeedableRng, TryCryptoRng, TryRng};
10use vitaminc_protected::Controlled;
11use zeroize::{ZeroizeOnDrop, Zeroizing};
12
13/// A secure random number generator that is safe to use for cryptographic purposes.
14///
15/// Wipes its key schedule and buffered keystream on drop.
16pub struct SafeRand(rand::rngs::ChaCha20Rng);
17
18// `SafeRand` has no `Drop` of its own; the wipe is the field's drop glue,
19// which is `ChaCha20Rng`'s `ZeroizeOnDrop`. That impl exists only when
20// `chacha20` is built with its `zeroize` feature (see this crate's
21// `Cargo.toml`). The bound below fails to compile if the feature ever lapses,
22// so the marker impl can never silently become a lie.
23impl ZeroizeOnDrop for SafeRand {}
24const _: fn() = assert_zeroize_on_drop::<rand::rngs::ChaCha20Rng>;
25fn assert_zeroize_on_drop<T: ZeroizeOnDrop>() {}
26
27impl SafeRand {
28 /// A value in `0..n`: at least `0`, strictly below `n`, uniform to
29 /// within the bias bound stated below. This is the bound every
30 /// index-shaped use wants (`n` items, pick one) and the one `std` and
31 /// `rand` ranges use.
32 ///
33 /// Exactly one 64-bit draw per call, reduced with Lemire's multiply-high
34 /// method: no rejection loop and no branch on the value drawn, so the
35 /// number of draws does not depend on the values drawn. That matters when
36 /// the generator is seeded from secret material, as in permutation-key
37 /// derivation, where a rejection loop's retry count would leak through
38 /// timing.
39 ///
40 /// The reduction's statistical distance from uniform is at most
41 /// `n / 2⁶⁴`: below 2⁻⁵⁵ for `n ≤ 256` and still at most 2⁻³² at
42 /// `n = u32::MAX`. A protocol that needs exact uniformity must account
43 /// for that term.
44 ///
45 /// The bound may be a plain `u32`, a [`Protected<u32>`], or a
46 /// [`Protected<NonZeroU32>`]; the last is the form for a secret bound
47 /// that might be zero, since it moves the zero check to construction and
48 /// makes the draw itself total. All three are the
49 /// [`BoundedRng::next_below`] trait method, reachable here without
50 /// importing the trait.
51 ///
52 /// # Panics
53 ///
54 /// Panics if `n == 0`: the range `0..0` is empty and has no value to
55 /// return. Callers that compute `n` should check it first. When `n` is
56 /// a [`Protected<u32>`] the panic is observable on a secret, so a caller
57 /// whose secret bound may be zero should pass a
58 /// [`Protected<NonZeroU32>`] instead, which never panics.
59 ///
60 /// [`Protected<u32>`]: vitaminc_protected::Protected
61 /// [`Protected<NonZeroU32>`]: vitaminc_protected::Protected
62 /// [`BoundedRng::next_below`]: crate::BoundedRng::next_below
63 pub fn next_below<T>(&mut self, n: T) -> <Self as crate::BoundedRng<T>>::Output
64 where
65 Self: crate::BoundedRng<T>,
66 {
67 <Self as crate::BoundedRng<T>>::next_below(self, n)
68 }
69
70 /// A value in `0..=max`, for every `max` up to and including `u32::MAX`,
71 /// with the same fixed-count draw and the same `(max + 1) / 2⁶⁴` bias
72 /// bound as [`next_below`](Self::next_below). This is the
73 /// [`BoundedRngInclusive::next_bounded`] trait method at `u32`.
74 ///
75 /// Deprecated: earlier versions honoured the inclusive bound only when
76 /// `max` was not a power of two and were exclusive otherwise, so callers
77 /// written against either meaning were wrong for some inputs
78 /// (cipherstash/vitaminc#198). The equivalent call is
79 /// `next_below(max + 1)` (for `max == u32::MAX` that is the whole word:
80 /// use [`Rng::next_u32`](rand::Rng::next_u32)), or `next_below(n)` when
81 /// the caller has a length `n` rather than a maximum.
82 ///
83 /// Besides the power-of-two case, both the value drawn for a given seed
84 /// and the number of words taken from the stream changed; see
85 /// [`BoundedRngInclusive`](crate::BoundedRngInclusive) for what that
86 /// means for existing callers.
87 ///
88 /// [`BoundedRngInclusive::next_bounded`]: crate::BoundedRngInclusive::next_bounded
89 #[deprecated(
90 note = "inclusive `0..=max`; use `next_below(max + 1)`, or `next_below(n)` when you have a length `n`"
91 )]
92 pub fn next_bounded_u32(&mut self, max: u32) -> u32 {
93 crate::bounded::upto_u32(self, max)
94 }
95
96 /// Creates a new `SafeRand` seeded from the OS random number generator.
97 pub fn from_entropy() -> Result<Self, crate::RandomError> {
98 Ok(Self::try_from_rng(&mut SysRng)?)
99 }
100
101 /// A safer alternative to `from_seed`: the seed is wiped once the
102 /// generator is built, on every exit from this function.
103 ///
104 /// The unwrapped bytes live in a [`Zeroizing`] wrapper from the moment
105 /// they leave `seed`'s custody, so the wipe is done by drop glue rather
106 /// than by a trailing statement. An unwind between unwrapping and
107 /// returning (e.g. a panic in the generator's constructor) still wipes
108 /// them.
109 pub fn from_controlled_seed<C>(seed: C) -> Self
110 where
111 C: Controlled<Inner = [u8; 32]>,
112 {
113 let seed = Zeroizing::new(seed.risky_unwrap());
114 Self(rand::rngs::ChaCha20Rng::from_seed(*seed))
115 }
116}
117
118impl TryCryptoRng for SafeRand {}
119
120impl TryRng for SafeRand {
121 type Error = Infallible;
122
123 #[inline]
124 fn try_next_u32(&mut self) -> Result<u32, Self::Error> {
125 Ok(self.0.next_u32())
126 }
127
128 #[inline]
129 fn try_next_u64(&mut self) -> Result<u64, Self::Error> {
130 Ok(self.0.next_u64())
131 }
132
133 #[inline]
134 fn try_fill_bytes(&mut self, dst: &mut [u8]) -> Result<(), Self::Error> {
135 self.0.fill_bytes(dst);
136 Ok(())
137 }
138}
139
140impl SeedableRng for SafeRand {
141 // TODO: This should be a ProtectedSeed! Maybe a GAT?
142 type Seed = [u8; 32];
143
144 fn from_seed(seed: Self::Seed) -> Self {
145 Self(rand::rngs::ChaCha20Rng::from_seed(seed))
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::SafeRand;
152 use rand::{rngs::ChaCha20Rng, Rng, SeedableRng, TryRng};
153
154 const SEED: [u8; 32] = [7u8; 32];
155
156 /// `SafeRand` is a transparent wrapper: for the same seed it must yield
157 /// exactly the ChaCha20 stream, word for word and byte for byte. Pins the
158 /// `TryRng` impl to real output rather than to "some value", which is
159 /// what a mutant returning a constant would otherwise pass as.
160 #[test]
161 fn try_rng_yields_the_chacha20_stream_for_the_seed() {
162 let mut safe = SafeRand::from_seed(SEED);
163 let mut reference = ChaCha20Rng::from_seed(SEED);
164
165 for _ in 0..4 {
166 assert_eq!(safe.try_next_u32().unwrap(), reference.next_u32());
167 }
168 for _ in 0..4 {
169 assert_eq!(safe.try_next_u64().unwrap(), reference.next_u64());
170 }
171 let mut got = [0u8; 40];
172 let mut want = [0u8; 40];
173 safe.try_fill_bytes(&mut got).unwrap();
174 reference.fill_bytes(&mut want);
175 assert_eq!(got, want);
176 assert_ne!(got, [0u8; 40], "fill_bytes must write the buffer");
177 }
178
179 /// What `next_below` promises, spelled out over the same seed: one
180 /// 64-bit draw, multiplied by `n`, high word kept. Comparing exact values
181 /// (not just `< n`) pins the draw width, the reduction, and that exactly
182 /// one draw is consumed per call, at powers of two, their neighbours, and
183 /// both ends of the `u32` range.
184 fn reference_below(rng: &mut ChaCha20Rng, n: u64) -> u32 {
185 ((u128::from(rng.next_u64()) * u128::from(n)) >> 64) as u32
186 }
187
188 const BOUNDS: [u32; 14] = [
189 1,
190 2,
191 3,
192 4,
193 5,
194 6,
195 7,
196 16,
197 100,
198 1000,
199 (1 << 31) - 1,
200 1 << 31,
201 (1 << 31) + 1,
202 u32::MAX,
203 ];
204
205 #[test]
206 fn next_below_matches_the_lemire_reference() {
207 for n in BOUNDS {
208 let mut safe = SafeRand::from_seed(SEED);
209 let mut reference = ChaCha20Rng::from_seed(SEED);
210 for _ in 0..256 {
211 let got = safe.next_below(n);
212 assert_eq!(
213 got,
214 reference_below(&mut reference, u64::from(n)),
215 "n = {n}"
216 );
217 assert!(got < n, "n = {n}");
218 }
219 }
220 }
221
222 /// The deprecated inclusive form is `next_below(max + 1)` for every
223 /// `max`, including `u32::MAX`, where `max + 1` does not fit a `u32`.
224 /// `BOUNDS` shifted down by one tops out at `u32::MAX - 1`, so
225 /// `u32::MAX` itself is appended: that is the input where the inclusive
226 /// path must widen to `u64` before adding one.
227 #[test]
228 #[allow(deprecated)]
229 fn next_bounded_u32_is_the_inclusive_form_of_next_below() {
230 for max in BOUNDS.map(|n| n - 1).into_iter().chain([u32::MAX]) {
231 let mut safe = SafeRand::from_seed(SEED);
232 let mut reference = ChaCha20Rng::from_seed(SEED);
233 for _ in 0..256 {
234 let got = safe.next_bounded_u32(max);
235 assert_eq!(
236 got,
237 reference_below(&mut reference, u64::from(max) + 1),
238 "max = {max}"
239 );
240 assert!(got <= max, "max = {max}");
241 }
242 }
243 }
244
245 /// Every value in `0..n` is reachable and `n` itself never is; the
246 /// power-of-two case is the one the old code got wrong.
247 #[test]
248 fn next_below_covers_exactly_the_half_open_range() {
249 for n in [5usize, 8] {
250 let mut rng = SafeRand::from_seed(SEED);
251 let mut seen = vec![false; n + 1];
252 for _ in 0..512 {
253 seen[rng.next_below(n as u32) as usize] = true;
254 }
255 assert!(seen[..n].iter().all(|&s| s), "n = {n}");
256 assert!(!seen[n], "n = {n}");
257 }
258 }
259
260 #[test]
261 #[should_panic(expected = "range must be non-zero")]
262 fn next_below_zero_panics() {
263 SafeRand::from_seed(SEED).next_below(0);
264 }
265
266 #[test]
267 fn different_seeds_diverge_and_the_same_seed_repeats() {
268 let mut a = SafeRand::from_seed(SEED);
269 let mut b = SafeRand::from_seed(SEED);
270 let mut c = SafeRand::from_seed([8u8; 32]);
271 let (x, y, z) = (
272 a.try_next_u64().unwrap(),
273 b.try_next_u64().unwrap(),
274 c.try_next_u64().unwrap(),
275 );
276 assert_eq!(x, y);
277 assert_ne!(x, z);
278 }
279
280 #[test]
281 fn next_below_from_entropy_is_half_open() -> Result<(), crate::RandomError> {
282 let mut rng = SafeRand::from_entropy()?;
283 for n in [1, 2, 4, 5, 52, 62, 64, 94, u32::MAX] {
284 for _ in 0..1000 {
285 assert!(rng.next_below(n) < n);
286 }
287 }
288 Ok(())
289 }
290
291 #[test]
292 fn next_below_is_uniform() {
293 // Chi-squared test over a non-power-of-two range with a fixed seed.
294 // Would catch a modulo-style bias or a broken reduction.
295 const RANGE: u32 = 5;
296 const SAMPLES: u32 = 100_000;
297 let mut rng = SafeRand::from_seed([3u8; 32]);
298 let mut counts = [0u32; RANGE as usize];
299 for _ in 0..SAMPLES {
300 counts[rng.next_below(RANGE) as usize] += 1;
301 }
302 let expected = f64::from(SAMPLES) / f64::from(RANGE);
303 let chi2: f64 = counts
304 .iter()
305 .map(|&c| {
306 let d = f64::from(c) - expected;
307 d * d / expected
308 })
309 .sum();
310 // 4 degrees of freedom; p = 0.001 critical value is 18.47. The seed is
311 // fixed, so this is deterministic — no flakiness.
312 assert!(chi2 < 18.47, "chi-squared too high: {chi2}");
313 }
314}