rustlavel_auth/random.rs
1//! Random bytes, read straight from the operating system.
2//!
3//! Session ids, CSRF tokens, encryption nonces and application keys all come
4//! from here. Unlike a protocol nonce, most of these values are secrets: an
5//! attacker who can predict a session id is logged in as somebody else. So the
6//! only source we trust is the OS CSPRNG, and a system that cannot provide one
7//! gets a loud error rather than a quiet downgrade.
8
9use std::io::Read;
10
11/// Fill a buffer with `length` bytes from the operating system's CSPRNG.
12///
13/// Reading `/dev/urandom` directly keeps the dependency list honest — it is
14/// the same syscall a `rand` crate would make. If it is unreachable (a
15/// chroot without `/dev`, a platform without it) the caller still gets bytes,
16/// but they are only unique, not unpredictable, and the process says so.
17pub fn bytes(length: usize) -> Vec<u8> {
18 let mut buffer = vec![0u8; length];
19
20 if let Ok(mut source) = std::fs::File::open("/dev/urandom")
21 && source.read_exact(&mut buffer).is_ok()
22 {
23 return buffer;
24 }
25
26 // No fallback. There used to be one — a xorshift seeded from the clock and
27 // a heap address — behind an error log, and it was the most dangerous line
28 // in the framework. This function is the only entropy source there is: it
29 // makes session ids, CSRF tokens, API token secrets, argon2 salts, AES-GCM
30 // nonces, WebAuthn challenges and `APP_KEY`. A session id drawn from 64
31 // bits of clock is a session id somebody who knows roughly when you signed
32 // in can enumerate; an `APP_KEY` drawn from it makes every signed cookie
33 // forgeable.
34 //
35 // The condition is not hypothetical: file-descriptor exhaustion is
36 // something an attacker can induce by opening connections, and the failure
37 // was silent apart from a log line nobody reads in time.
38 //
39 // So this stops. A process that cannot obtain randomness must not go on
40 // inventing secrets, and there is no safe value to return from here.
41 panic!(
42 "could not read /dev/urandom, and there is no safe substitute. Every secret this \
43 framework makes — session ids, CSRF tokens, API tokens, password salts, encryption \
44 nonces, APP_KEY — comes from here, so it stops rather than issue one that can be \
45 guessed. Check the file-descriptor limit and that /dev is mounted."
46 );
47}
48
49/// `length` random bytes rendered as lowercase hex.
50///
51/// Hex rather than an alphabet lookup because folding random bytes into a
52/// 62-character alphabet with `%` biases the result; hex is a bijection, so a
53/// 32-byte id really carries 256 bits.
54pub fn hex(length: usize) -> String {
55 let mut out = String::with_capacity(length * 2);
56 for byte in bytes(length) {
57 out.push(char::from_digit((byte >> 4) as u32, 16).unwrap_or('0'));
58 out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap_or('0'));
59 }
60 out
61}
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66
67 #[test]
68 fn returns_the_requested_number_of_bytes() {
69 assert_eq!(bytes(32).len(), 32);
70 assert_eq!(bytes(0).len(), 0);
71 }
72
73 #[test]
74 fn hex_output_is_twice_as_long_and_lowercase_hexadecimal() {
75 let value = hex(32);
76
77 assert_eq!(value.len(), 64);
78 assert!(value.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
79 }
80
81 #[test]
82 fn two_draws_of_the_same_length_differ() {
83 assert_ne!(hex(32), hex(32));
84 }
85
86 /// There must be no way to get a secret out of here that did not come from
87 /// the operating system.
88 ///
89 /// The test this replaces asserted that the *fallback* filled every byte —
90 /// it guarded the shape of a mixer that should never have been reachable,
91 /// and reading it was what made the fallback look considered rather than
92 /// dangerous. A test can only check that nothing like it comes back.
93 #[test]
94 fn there_is_no_substitute_for_the_operating_systems_randomness() {
95 let source = include_str!("random.rs");
96 // Comments stripped: the note above `bytes` names the mixer it removed,
97 // which is worth keeping and is not code.
98 let code: String = source
99 .split("#[cfg(test)]")
100 .next()
101 .expect("there is code above the tests")
102 .lines()
103 .filter(|line| !line.trim_start().starts_with("//"))
104 .collect::<Vec<_>>()
105 .join("\n");
106 for banned in ["xorshift", "SystemTime::now", "Box::into_raw", "fn fallback"] {
107 assert!(
108 !code.contains(banned),
109 "`{banned}` is back in the entropy path. Every secret the framework issues comes \
110 from this function; a clock- or address-seeded value here is a guessable \
111 session id, and the failure is silent."
112 );
113 }
114 }
115}