Skip to main content

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}