Skip to main content

modo/auth/
backup.rs

1use subtle::ConstantTimeEq;
2
3const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
4
5/// Generates `count` one-time backup recovery codes.
6///
7/// Each code is formatted as `xxxx-xxxx` (8 lowercase alphanumeric characters
8/// split by a hyphen). Returns a `Vec` of `(plaintext_code, sha256_hex_hash)`
9/// tuples. Store only the hashes; display the plaintext codes to the user
10/// once. Verify a submitted code with [`verify`].
11///
12/// Uses rejection sampling over `OsRng` to avoid modulo bias.
13///
14/// Requires feature `"auth"`.
15pub fn generate(count: usize) -> Vec<(String, String)> {
16    (0..count).map(|_| generate_one()).collect()
17}
18
19/// Verifies `code` against a SHA-256 hex `hash` produced by [`generate`].
20///
21/// Normalizes `code` before hashing (strips hyphens, lowercases) so that
22/// users can submit codes with or without the separator. Comparison is
23/// constant-time to prevent timing attacks.
24///
25/// Requires feature `"auth"`.
26pub fn verify(code: &str, hash: &str) -> bool {
27    let normalized = normalize(code);
28    let computed = sha256_hex(&normalized);
29    computed.as_bytes().ct_eq(hash.as_bytes()).into()
30}
31
32fn generate_one() -> (String, String) {
33    let mut chars = Vec::with_capacity(8);
34    for _ in 0..8 {
35        let mut byte = [0u8; 1];
36        loop {
37            rand::fill(&mut byte);
38            // Rejection sampling: ALPHABET.len()=36, accept <252 to avoid modulo bias (252 = 36*7)
39            if byte[0] < 252 {
40                chars.push(ALPHABET[(byte[0] as usize) % ALPHABET.len()] as char);
41                break;
42            }
43        }
44    }
45
46    let plaintext = format!(
47        "{}-{}",
48        chars[..4].iter().collect::<String>(),
49        chars[4..].iter().collect::<String>(),
50    );
51    let hash = sha256_hex(&normalize(&plaintext));
52    (plaintext, hash)
53}
54
55fn normalize(code: &str) -> String {
56    code.replace('-', "").to_lowercase()
57}
58
59fn sha256_hex(input: &str) -> String {
60    crate::encoding::hex::sha256(input)
61}