Skip to main content

torrust_tracker_deployer_lib/shared/secrets/
random.rs

1//! Cryptographically random password generation
2
3use rand::seq::IndexedRandom as _;
4use rand::seq::SliceRandom as _;
5use rand::Rng as _;
6
7use super::password::Password;
8
9const LOWER: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
10const UPPER: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
11const DIGIT: &[u8] = b"0123456789";
12// Note: `{`, `}`, `<`, `>` are shell redirection/expansion characters.
13// They are safe inside Docker `.env` quoted values but may need escaping
14// if the password is ever interpolated in a raw shell context.
15const SYMBOL: &[u8] = b"!@#$%^&*()-_=+[]{}<>?";
16
17fn full_charset() -> Vec<u8> {
18    [LOWER, UPPER, DIGIT, SYMBOL].concat()
19}
20
21/// Generate a cryptographically secure MySQL-compatible password.
22///
23/// Design rationale:
24/// - `rand::rng()`: thread-local CSPRNG seeded from the OS and periodically
25///   reseeded — suitable for secrets in rand 0.9 (direct `OsRng` no longer
26///   implements the high-level `Rng` trait required by `choose`/`shuffle`)
27/// - `choose`: avoids modulo bias — uniform distribution
28/// - Explicit class inclusion: satisfies `MySQL` `validate_password` MEDIUM policy
29/// - Shuffle: removes structural bias from fixed positions
30///
31/// The generated password is 32 characters long and always contains at least
32/// one lowercase letter, one uppercase letter, one digit, and one symbol.
33///
34/// # Panics
35///
36/// Panics if any character set constant is empty, which cannot happen in practice
37/// as they are defined as non-empty byte string literals.
38#[must_use]
39pub fn generate_random_password() -> Password {
40    let mut rng = rand::rng();
41
42    // Ensure required character classes (MySQL policy compliance)
43    let mut password: Vec<u8> = vec![
44        *LOWER
45            .choose(&mut rng)
46            .expect("LOWER charset is non-empty; selection must succeed"),
47        *UPPER
48            .choose(&mut rng)
49            .expect("UPPER charset is non-empty; selection must succeed"),
50        *DIGIT
51            .choose(&mut rng)
52            .expect("DIGIT charset is non-empty; selection must succeed"),
53        *SYMBOL
54            .choose(&mut rng)
55            .expect("SYMBOL charset is non-empty; selection must succeed"),
56    ];
57
58    // Fill remaining characters with maximum entropy
59    let charset = full_charset();
60    for _ in password.len()..32 {
61        let idx = rng.random_range(0..charset.len());
62        password.push(charset[idx]);
63    }
64
65    // Remove positional bias
66    password.shuffle(&mut rng);
67
68    // Safe: charset only contains valid ASCII bytes
69    Password::new(
70        String::from_utf8(password)
71            .expect("Generated password contains only valid ASCII; UTF-8 conversion must succeed"),
72    )
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78
79    #[test]
80    fn it_should_generate_password_satisfying_mysql_medium_policy() {
81        for _ in 0..100 {
82            let pwd = generate_random_password();
83            let s = pwd.expose_secret();
84
85            assert_eq!(s.len(), 32, "password must be 32 characters");
86            assert!(s.chars().any(char::is_uppercase), "must contain uppercase");
87            assert!(s.chars().any(char::is_lowercase), "must contain lowercase");
88            assert!(s.chars().any(|c| c.is_ascii_digit()), "must contain digit");
89            assert!(
90                s.chars().any(|c| "!@#$%^&*()-_=+[]{}<>?".contains(c)),
91                "must contain symbol"
92            );
93            assert!(s.is_ascii(), "must be ASCII (safe in .env files and shell)");
94        }
95    }
96
97    #[test]
98    fn it_should_generate_unique_passwords() {
99        let a = generate_random_password();
100        let b = generate_random_password();
101        assert_ne!(
102            a.expose_secret(),
103            b.expose_secret(),
104            "two consecutive calls must not produce the same password"
105        );
106    }
107}