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}