Skip to main content

ullun/
security.rs

1//! Small security helpers: secure random tokens and constant-time comparison
2//!
3//! ```
4//! use ullun::security::{constant_time_eq, random_token};
5//!
6//! let token = random_token(16).unwrap();          // 128 bits as 32 hex characters
7//! assert_eq!(token.len(), 32);
8//! assert!(constant_time_eq(token.as_bytes(), token.as_bytes()));
9//! ```
10
11use crate::error::{Error, Result};
12
13/// Compare two byte strings without returning early on the first difference,
14/// so the time taken does not reveal how much of a secret was guessed right.
15/// Different lengths compare unequal (the length itself is not hidden).
16pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
17    let mut diff = (a.len() ^ b.len()) as u8;
18    for i in 0..a.len().max(b.len()) {
19        diff |= a.get(i).copied().unwrap_or(0) ^ b.get(i).copied().unwrap_or(0);
20    }
21    std::hint::black_box(diff) == 0
22}
23
24/// `len` bytes from the operating system's CSPRNG
25pub fn random_bytes(len: usize) -> Result<Vec<u8>> {
26    let mut buf = vec![0u8; len];
27    getrandom::fill(&mut buf)
28        .map_err(|e| Error::internal(format!("Random number generator unavailable: {}", e)))?;
29    Ok(buf)
30}
31
32/// Unguessable token: `bytes` random bytes as lowercase hex (`2 * bytes`
33/// characters). Use at least 16 bytes for session ids and secrets.
34pub fn random_token(bytes: usize) -> Result<String> {
35    let mut out = String::with_capacity(bytes * 2);
36    for b in random_bytes(bytes)? {
37        out.push(char::from_digit(u32::from(b >> 4), 16).unwrap_or('0'));
38        out.push(char::from_digit(u32::from(b & 0xf), 16).unwrap_or('0'));
39    }
40    Ok(out)
41}
42
43/// Random string of `len` characters from `[A-Za-z0-9]`, uniformly distributed
44/// (rejection sampling). Handy for short public ids; for secrets prefer
45/// [`random_token`].
46pub fn random_alphanumeric(len: usize) -> Result<String> {
47    const ALPHABET: &[u8; 62] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
48    let mut out = String::with_capacity(len);
49    while out.len() < len {
50        for b in random_bytes(len.max(8))? {
51            // 248 = 4 * 62: values above would skew the distribution
52            if b < 248 && out.len() < len {
53                out.push(ALPHABET[usize::from(b) % 62] as char);
54            }
55        }
56    }
57    Ok(out)
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63    use std::collections::HashSet;
64
65    #[test]
66    fn constant_time_eq_semantics() {
67        assert!(constant_time_eq(b"", b""));
68        assert!(constant_time_eq(b"secret", b"secret"));
69        assert!(!constant_time_eq(b"secret", b"secreT"));
70        assert!(!constant_time_eq(b"secret", b"secre"));
71        assert!(!constant_time_eq(b"", b"x"));
72        // a differing length must not be masked by a matching prefix
73        assert!(!constant_time_eq(b"abc", b"abc\0"));
74    }
75
76    #[test]
77    fn token_shape_and_uniqueness() {
78        let tokens: HashSet<String> = (0..200).map(|_| random_token(16).unwrap()).collect();
79        assert_eq!(tokens.len(), 200);
80        assert!(tokens.iter().all(|t| t.len() == 32
81            && t.bytes()
82                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())));
83        assert_eq!(random_token(0).unwrap(), "");
84    }
85
86    #[test]
87    fn alphanumeric_shape_and_coverage() {
88        let s = random_alphanumeric(2000).unwrap();
89        assert_eq!(s.len(), 2000);
90        assert!(s.bytes().all(|b| b.is_ascii_alphanumeric()));
91        // all 62 symbols should appear in 2000 draws (miss chance ~ 62 * e^-32)
92        let distinct: HashSet<u8> = s.bytes().collect();
93        assert_eq!(distinct.len(), 62);
94        assert_eq!(random_alphanumeric(0).unwrap(), "");
95    }
96}