Skip to main content

acme_proxy_admin/admin/
recovery.rs

1//! Recovery codes: the way back in when the phone holding the TOTP secret is
2//! gone.
3//!
4//! Ten single-use codes, minted when a factor is confirmed and shown exactly
5//! once -- the treatment `eab create` already gives its HMAC secret. Stored
6//! one-way through [`crate::admin::password`], because a recovery code is a
7//! *password* in every respect that matters: it is only ever compared, never
8//! needed back, so the database holds nothing replayable. That is the opposite
9//! of `admin_users.totp_secret`, which verification needs in the clear on every
10//! attempt.
11//!
12//! This module is generation and normalisation only. Consumption is a single
13//! `UPDATE … WHERE id = ? AND used_at IS NULL` in
14//! [`acme_proxy_store::admin_recovery_code`], because "single-use" has to be
15//! decided by the database rather than by a read followed by a write.
16
17use ring::rand::{SecureRandom, SystemRandom};
18
19use crate::admin::totp::BASE32_ALPHABET;
20
21/// Codes minted per set. Enough to cover a lost phone and a few false starts
22/// without becoming a list nobody stores anywhere safe.
23pub const CODE_COUNT: usize = 10;
24
25/// Characters per code, from a 32-symbol alphabet: 50 bits, which is far past
26/// what the login limiter and a `pending_mfa` session's five-minute life leave
27/// guessable.
28pub const CODE_LEN: usize = 10;
29
30/// Where the separator goes when a code is rendered for a human.
31const GROUP_LEN: usize = 5;
32
33/// Mints a fresh set. The caller hashes them, stores the hashes, and shows
34/// these strings once.
35#[must_use]
36pub fn generate_codes() -> Vec<String> {
37    (0..CODE_COUNT).map(|_| generate_code()).collect()
38}
39
40/// One code, formatted for someone reading it off a screen and typing it back:
41/// `K7QF2-3BXTM`.
42#[must_use]
43fn generate_code() -> String {
44    let mut bytes = [0u8; CODE_LEN];
45    // Same trade-off as `totp::generate_secret`: an unavailable system RNG is
46    // unrecoverable, and threading the error out would only move the panic.
47    SystemRandom::new()
48        .fill(&mut bytes)
49        .expect("system RNG unavailable");
50
51    let mut code = String::with_capacity(CODE_LEN + 1);
52    for (index, byte) in bytes.iter().enumerate() {
53        if index > 0 && index % GROUP_LEN == 0 {
54            code.push('-');
55        }
56        // Modulo bias over a 32-symbol alphabet from a 256-value byte is
57        // exactly zero: 256 is a multiple of 32.
58        code.push(BASE32_ALPHABET[usize::from(*byte) % BASE32_ALPHABET.len()] as char);
59    }
60    code
61}
62
63/// Canonicalises a submitted code so the grouping and the case an operator
64/// typed cannot make a correct code fail.
65///
66/// Deliberately **no** confusable-character fixup (`O`→`0`, `l`→`1`): the
67/// alphabet is RFC 4648 base32, which already excludes `0`, `1`, `8` and `9`,
68/// so there is nothing to confuse them *with*. A fixup would only widen what
69/// counts as a match.
70#[must_use]
71pub fn normalize(raw: &str) -> String {
72    raw.chars()
73        .filter(|character| character.is_ascii_alphanumeric())
74        .map(|character| character.to_ascii_uppercase())
75        .collect()
76}
77
78/// Whether `candidate` could be a recovery code at all.
79///
80/// Lets the second-factor path skip up to ten PBKDF2 runs on something that is
81/// plainly a mistyped TOTP code -- the same "refuse the wrong shape before any
82/// crypto" rule [`crate::admin::totp::verify`] follows.
83#[must_use]
84pub fn is_well_formed(candidate: &str) -> bool {
85    candidate.len() == CODE_LEN
86        && candidate
87            .bytes()
88            .all(|byte| BASE32_ALPHABET.contains(&byte))
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::collections::HashSet;
95
96    #[test]
97    fn a_set_is_ten_distinct_codes_from_the_base32_alphabet() {
98        let codes = generate_codes();
99        assert_eq!(codes.len(), CODE_COUNT);
100
101        let distinct: HashSet<&String> = codes.iter().collect();
102        assert_eq!(
103            distinct.len(),
104            CODE_COUNT,
105            "codes must not repeat: {codes:?}"
106        );
107
108        for code in &codes {
109            // `K7QF2-3BXTM`: two groups of five with one separator.
110            assert_eq!(code.len(), CODE_LEN + 1, "{code}");
111            assert_eq!(code.chars().nth(GROUP_LEN), Some('-'), "{code}");
112
113            let normalized = normalize(code);
114            assert!(
115                is_well_formed(&normalized),
116                "a freshly minted code must pass the shape check: {code}"
117            );
118        }
119    }
120
121    #[test]
122    fn normalize_absorbs_the_ways_a_human_retypes_a_code() {
123        let canonical = "K7QF23BXTM";
124        for typed in [
125            "K7QF2-3BXTM",
126            "k7qf2-3bxtm",
127            " K7QF2 3BXTM ",
128            "K7QF2\t3BXTM\n",
129            "K7QF23BXTM",
130            "K7-QF2-3B-XTM",
131        ] {
132            assert_eq!(normalize(typed), canonical, "input {typed:?}");
133        }
134
135        // Idempotent, since the login path normalises once and the tests
136        // normalise again.
137        assert_eq!(normalize(canonical), canonical);
138        assert_eq!(normalize(&normalize("k7qf2-3bxtm")), canonical);
139    }
140
141    #[test]
142    fn the_shape_check_refuses_what_is_plainly_not_a_recovery_code() {
143        assert!(is_well_formed("K7QF23BXTM"));
144
145        let cases = [
146            ("empty", ""),
147            ("a totp code", "123456"),
148            ("too short", "K7QF23BXT"),
149            ("too long", "K7QF23BXTMM"),
150            // The four characters the alphabet leaves out, which is what makes
151            // the confusable fixup unnecessary.
152            ("contains 0", "K7QF23BXT0"),
153            ("contains 1", "K7QF23BXT1"),
154            ("contains 8", "K7QF23BXT8"),
155            ("contains 9", "K7QF23BXT9"),
156            ("still grouped", "K7QF2-3BXT"),
157            ("lowercase", "k7qf23bxtm"),
158        ];
159        for (name, candidate) in cases {
160            assert!(!is_well_formed(candidate), "case `{name}`");
161        }
162    }
163}