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}