Skip to main content

acme_proxy_admin/admin/
totp.rs

1//! RFC 6238 time-based one-time passwords, over RFC 4226's HOTP.
2//!
3//! Hand-rolled on `ring::hmac`, which is already this crate's crypto backend
4//! everywhere else, rather than pulling a TOTP crate in: the whole primitive is
5//! an HMAC, an eight-byte counter and RFC 4226 §5.4's dynamic truncation, and
6//! both RFCs publish test vectors, so the hand-rolled version is checkable
7//! against the same authority a dependency would be. Same bar
8//! [`crate::admin::password`]'s module doc sets when it rejects Argon2id.
9//!
10//! This module holds no database access and no I/O -- the split
11//! [`acme_proxy_core::eab`] (pure verification) and `acme_proxy_store::eab` (persistence) already
12//! make. The replay guard RFC 6238 §5.2 asks for needs a row, so it lives in
13//! `AdminUser::claim_totp_step`; [`verify`] deliberately knows nothing about
14//! it.
15//!
16//! ## Why HMAC-**SHA-1**
17//!
18//! `crates/core/src/eab.rs` uses HMAC-SHA256, so SHA-256 looks like the house style here.
19//! It is the wrong choice: **Google Authenticator ignores the `algorithm=`
20//! parameter of an `otpauth://` URI and always computes SHA-1**, so an operator
21//! enrolling with the most widely deployed authenticator would get an entry
22//! producing wrong codes forever, with no diagnosis available from either side.
23//! A second factor nobody can enrol is not a second factor.
24//!
25//! SHA-1's collision attacks do not weaken HMAC-SHA1 -- HMAC's security rests
26//! on the compression function being a PRF, not on collision resistance, which
27//! is why RFC 6238 §1.2 and NIST SP 800-107 both still specify it here. The
28//! `ring` constant is named `HMAC_SHA1_FOR_LEGACY_USE_ONLY`, so **this comment
29//! is the reason not to "fix" it.**
30//!
31//! The stored secret carries no algorithm tag, so changing this later means
32//! re-enrolment -- one `acme-proxy admin user totp reset` per operator, which
33//! is exactly why that command exists.
34
35use ring::hmac;
36use ring::rand::{SecureRandom, SystemRandom};
37use subtle::ConstantTimeEq;
38use url::Url;
39
40/// RFC 4648 §6's alphabet. Shared with [`crate::admin::recovery`], so there is
41/// one table and one test: it already excludes `0`, `1`, `8` and `9`, which is
42/// what lets a recovery code be read off a screen without a confusable fixup.
43pub(crate) const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
44
45/// 160 bits: RFC 4226 §4 R6's floor, what every authenticator expects, and
46/// exactly 32 base32 characters -- so the encoder never has to pad and the
47/// operator never has to type a `=`.
48pub const SECRET_LEN: usize = 20;
49
50/// RFC 6238 §4's default time step, and universally assumed by clients.
51pub const PERIOD_SECONDS: i64 = 30;
52
53/// Code length. Six is what every authenticator renders.
54pub const DIGITS: u32 = 6;
55
56/// RFC 6238 §5.2 permits one step of clock skew either way. More widens the
57/// guessing window for no usability gain.
58pub const SKEW_STEPS: i64 = 1;
59
60/// The issuer this server names itself as in an `otpauth://` URI. It is what an
61/// authenticator app shows above the code.
62pub const ISSUER: &str = "acme-proxy";
63
64/// A generated enrolment: the bytes to store, and the two things the operator
65/// must see exactly once.
66#[derive(Debug, Clone)]
67pub struct Enrolment {
68    /// The raw secret, for `admin_users.totp_pending_secret`.
69    pub secret: Vec<u8>,
70    /// The same secret as the operator types it into an app.
71    pub secret_base32: String,
72    /// The `otpauth://` URI, for a QR code or a click-through.
73    pub uri: String,
74}
75
76/// Mints a secret and the two representations of it the enrolment page shows.
77///
78/// `account` is what distinguishes two entries in one authenticator, so it
79/// carries the operator's name and the host they administer -- see
80/// [`account_label`].
81#[must_use]
82pub fn begin_enrolment(issuer: &str, account: &str) -> Enrolment {
83    let secret = generate_secret();
84    let secret_base32 = base32_encode(&secret);
85    let uri = provisioning_uri(&secret_base32, issuer, account);
86    Enrolment {
87        secret,
88        secret_base32,
89        uri,
90    }
91}
92
93/// [`SECRET_LEN`] bytes from the system CSPRNG.
94#[must_use]
95pub fn generate_secret() -> Vec<u8> {
96    let mut secret = vec![0u8; SECRET_LEN];
97    // Same trade-off as `password::hash_with_iterations` and
98    // `acme_proxy_store::eab::generate_secret`: an unavailable system RNG is
99    // unrecoverable, and threading the error out would only move the panic.
100    SystemRandom::new()
101        .fill(&mut secret)
102        .expect("system RNG unavailable");
103    secret
104}
105
106/// The account half of an `otpauth://` label: `alice@admin.example.com`.
107///
108/// The host disambiguates two acme-proxy instances in one authenticator, which
109/// is why `admin.base_url` is load-bearing here as well as in the CSRF origin
110/// check and the generated certificate's name. A `base_url` that does not parse
111/// or has no host cannot reach this -- `webadmin::check_config` refuses to start
112/// on one -- but fall back to the bare username rather than panicking on a
113/// caller that skipped it.
114#[must_use]
115pub fn account_label(username: &str, base_url: &str) -> String {
116    match Url::parse(base_url).ok().and_then(|url| {
117        url.host_str()
118            .filter(|host| !host.is_empty())
119            .map(str::to_string)
120    }) {
121        Some(host) => format!("{username}@{host}"),
122        None => username.to_string(),
123    }
124}
125
126/// Builds the `otpauth://totp/<issuer>:<account>?…` URI.
127///
128/// Built through `url` rather than `format!` on purpose: `account` carries an
129/// operator-supplied username, which is only lowercased and trimmed and may
130/// therefore hold `/`, `?`, `#` or a space. `PathSegmentsMut::push`
131/// percent-encodes the whole label as one segment, including a `/` that would
132/// otherwise silently become a second path component.
133#[must_use]
134pub fn provisioning_uri(secret_base32: &str, issuer: &str, account: &str) -> String {
135    let mut url = Url::parse("otpauth://totp").expect("a constant, valid URL");
136    url.path_segments_mut()
137        .expect("otpauth://totp has an authority, so it can be a base")
138        .push(&format!("{issuer}:{account}"));
139
140    url.query_pairs_mut()
141        .append_pair("secret", secret_base32)
142        .append_pair("issuer", issuer)
143        // Emitted although the apps that matter ignore it -- see the module
144        // doc. The ones that do read it must not guess.
145        .append_pair("algorithm", "SHA1")
146        .append_pair("digits", &DIGITS.to_string())
147        .append_pair("period", &PERIOD_SECONDS.to_string());
148
149    url.to_string()
150}
151
152/// RFC 4648 §6 base32: uppercase, **unpadded**.
153///
154/// Only an encoder exists, and deliberately so: enrolment generates a secret,
155/// stores the raw bytes and shows the base32 for the operator to type into an
156/// app. Nothing in this server ever reads base32 back, so a decoder would be
157/// untested surface.
158#[must_use]
159pub fn base32_encode(bytes: &[u8]) -> String {
160    let mut encoded = String::with_capacity(bytes.len().div_ceil(5) * 8);
161
162    for chunk in bytes.chunks(5) {
163        // Up to 40 bits, big-endian and left-aligned inside the low 40 bits:
164        // the first input byte occupies bits 39..32.
165        let mut buffer: u64 = 0;
166        for (index, byte) in chunk.iter().enumerate() {
167            buffer |= u64::from(*byte) << (32 - 8 * index);
168        }
169
170        // How many whole 5-bit groups this many input bits actually fill.
171        // Anything beyond is the padding RFC 4648 defines and this does not
172        // emit.
173        let groups = match chunk.len() {
174            1 => 2,
175            2 => 4,
176            3 => 5,
177            4 => 7,
178            _ => 8,
179        };
180        for group in 0..groups {
181            let value = ((buffer >> (35 - 5 * group)) & 0x1f) as usize;
182            encoded.push(BASE32_ALPHABET[value] as char);
183        }
184    }
185
186    encoded
187}
188
189/// RFC 6238 §4's `T`: the number of whole [`PERIOD_SECONDS`] windows since the
190/// epoch.
191///
192/// `div_euclid`, not `/`: truncating division rounds a pre-epoch instant
193/// towards zero, which would make two adjacent negative seconds share a step
194/// boundary they should not.
195#[must_use]
196pub fn step_at(unix_seconds: i64) -> i64 {
197    unix_seconds.div_euclid(PERIOD_SECONDS)
198}
199
200/// RFC 4226 §5.3's HOTP, truncated to `digits` decimal digits.
201///
202/// `digits` is a parameter although production only ever passes [`DIGITS`]:
203/// RFC 6238's published vectors are 8-digit, and a core that cannot produce
204/// them cannot be checked against them.
205#[must_use]
206pub fn hotp(secret: &[u8], counter: u64, digits: u32) -> String {
207    let key = hmac::Key::new(hmac::HMAC_SHA1_FOR_LEGACY_USE_ONLY, secret);
208    let tag = hmac::sign(&key, &counter.to_be_bytes());
209    let digest = tag.as_ref();
210
211    // RFC 4226 §5.4's dynamic truncation: the low nibble of the last byte picks
212    // where to read four bytes from, and the top bit is masked so the result is
213    // a positive 31-bit integer on every platform's notion of signedness.
214    let offset = (digest[digest.len() - 1] & 0x0f) as usize;
215    let binary = (u32::from(digest[offset] & 0x7f) << 24)
216        | (u32::from(digest[offset + 1]) << 16)
217        | (u32::from(digest[offset + 2]) << 8)
218        | u32::from(digest[offset + 3]);
219
220    // 10^10 overflows a u32, and a zero-digit code is not a code.
221    let digits = digits.clamp(1, 9);
222    let width = digits as usize;
223    format!("{:0width$}", binary % 10u32.pow(digits))
224}
225
226/// The code for one time step.
227#[must_use]
228pub fn totp_at(secret: &[u8], step: i64, digits: u32) -> String {
229    // RFC 6238 §4.2 defines the counter as the time step itself. A negative
230    // step is pre-epoch and unreachable from a real clock; wrapping it is
231    // deterministic, which is all the tests need.
232    hotp(secret, step as u64, digits)
233}
234
235/// Verifies `code` against `secret` around `now_unix`, returning the time step
236/// it matched.
237///
238/// Every candidate in the ±[`SKEW_STEPS`] window is compared with
239/// `subtle::ConstantTimeEq` and the loop **does not short-circuit**, so neither
240/// the timing nor the number of comparisons says which step matched. A code of
241/// the wrong shape is refused before any HMAC runs.
242///
243/// Deliberately does **not** consult the replay guard: that needs the database.
244/// `admin::mfa::verify_second_factor` calls this, then
245/// `AdminUser::claim_totp_step` with the step returned here.
246#[must_use]
247pub fn verify(secret: &[u8], code: &str, now_unix: i64) -> Option<i64> {
248    let width = DIGITS as usize;
249    if code.len() != width || !code.bytes().all(|byte| byte.is_ascii_digit()) {
250        return None;
251    }
252
253    let current = step_at(now_unix);
254    let mut matched: Option<i64> = None;
255    for step in (current - SKEW_STEPS)..=(current + SKEW_STEPS) {
256        let candidate = totp_at(secret, step, DIGITS);
257        if bool::from(candidate.as_bytes().ct_eq(code.as_bytes())) {
258            matched = Some(step);
259        }
260    }
261    matched
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    /// The seed both RFCs publish their vectors against: the ASCII string
269    /// `"12345678901234567890"`.
270    const RFC_SEED: &[u8] = b"12345678901234567890";
271
272    /// RFC 4226 Appendix D, the 6-digit column -- the production shape. This is
273    /// what pins the dynamic truncation: an off-by-one in the offset or a
274    /// missing `& 0x7f` moves every one of these.
275    #[test]
276    fn rfc_4226_appendix_d_vectors() {
277        let expected = [
278            "755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583",
279            "399871", "520489",
280        ];
281        for (counter, want) in expected.iter().enumerate() {
282            assert_eq!(
283                &hotp(RFC_SEED, counter as u64, DIGITS),
284                want,
285                "HOTP counter {counter}"
286            );
287        }
288    }
289
290    /// RFC 6238 Appendix B, the SHA-1 rows. 8 digits, which is why [`hotp`]
291    /// takes `digits` at all.
292    #[test]
293    fn rfc_6238_appendix_b_sha1_vectors() {
294        let cases = [
295            (59_i64, "94287082"),
296            (1_111_111_109, "07081804"),
297            (1_111_111_111, "14050471"),
298            (1_234_567_890, "89005924"),
299            (2_000_000_000, "69279037"),
300            (20_000_000_000, "65353130"),
301        ];
302        for (time, want) in cases {
303            assert_eq!(
304                totp_at(RFC_SEED, step_at(time), 8),
305                want,
306                "RFC 6238 vector at T = {time}"
307            );
308        }
309    }
310
311    /// RFC 4648 §10, with the padding stripped: every residue class of the
312    /// 5-bit chunker, which is the only place this encoder can go wrong.
313    #[test]
314    fn rfc_4648_base32_vectors_unpadded() {
315        let cases = [
316            ("", ""),
317            ("f", "MY"),
318            ("fo", "MZXQ"),
319            ("foo", "MZXW6"),
320            ("foob", "MZXW6YQ"),
321            ("fooba", "MZXW6YTB"),
322            ("foobar", "MZXW6YTBOI"),
323        ];
324        for (input, want) in cases {
325            assert_eq!(base32_encode(input.as_bytes()), want, "base32({input:?})");
326        }
327    }
328
329    #[test]
330    fn a_generated_secret_is_exactly_thirty_two_unpadded_characters() {
331        let secret = generate_secret();
332        assert_eq!(secret.len(), SECRET_LEN);
333
334        let encoded = base32_encode(&secret);
335        assert_eq!(
336            encoded.len(),
337            32,
338            "160 bits is chosen so the operator never has to type a `=`"
339        );
340        assert!(!encoded.contains('='));
341        assert!(
342            encoded.bytes().all(|byte| BASE32_ALPHABET.contains(&byte)),
343            "every character must come from the RFC 4648 alphabet"
344        );
345
346        // Two secrets in a row must differ, or the RNG is not wired up.
347        assert_ne!(secret, generate_secret());
348    }
349
350    #[test]
351    fn a_code_is_accepted_one_step_either_side_and_no_further() {
352        let secret = generate_secret();
353        let now = 1_700_000_000_i64;
354        let current = step_at(now);
355
356        for offset in [-1_i64, 0, 1] {
357            let code = totp_at(&secret, current + offset, DIGITS);
358            assert_eq!(
359                verify(&secret, &code, now),
360                Some(current + offset),
361                "a code {offset} steps away must be accepted, and report its own step"
362            );
363        }
364
365        for offset in [-2_i64, 2, 10] {
366            let code = totp_at(&secret, current + offset, DIGITS);
367            assert_eq!(
368                verify(&secret, &code, now),
369                None,
370                "a code {offset} steps away is outside the window"
371            );
372        }
373    }
374
375    #[test]
376    fn a_wrong_shaped_code_is_refused_before_any_hmac() {
377        let secret = generate_secret();
378        let now = 1_700_000_000_i64;
379
380        let cases = [
381            ("empty", ""),
382            ("too short", "12345"),
383            ("too long", "1234567"),
384            ("not all digits", "12a456"),
385            ("leading space", " 12345"),
386            ("trailing space", "12345 "),
387            // Six characters, but not six bytes -- `code.len()` is bytes, so
388            // this must be refused by the length check rather than reaching the
389            // comparison.
390            ("non-ascii", "123456"),
391        ];
392        for (name, code) in cases {
393            assert_eq!(verify(&secret, code, now), None, "case `{name}`");
394        }
395    }
396
397    #[test]
398    fn step_at_does_not_straddle_the_epoch() {
399        assert_eq!(step_at(0), 0);
400        assert_eq!(step_at(29), 0);
401        assert_eq!(step_at(30), 1);
402        assert_eq!(step_at(59), 1);
403        // Truncating division would give 0 for both of these, merging two
404        // distinct windows into one.
405        assert_eq!(step_at(-1), -1);
406        assert_eq!(step_at(-30), -1);
407        assert_eq!(step_at(-31), -2);
408    }
409
410    #[test]
411    fn the_provisioning_uri_carries_every_parameter_an_app_reads() {
412        let uri = provisioning_uri("ABCDEFGH", ISSUER, "alice@admin.example.com");
413        assert_eq!(
414            uri,
415            "otpauth://totp/acme-proxy:alice@admin.example.com\
416             ?secret=ABCDEFGH&issuer=acme-proxy&algorithm=SHA1&digits=6&period=30"
417        );
418    }
419
420    /// A username is only trimmed and lowercased, never restricted to a
421    /// charset, so the label must survive one that would otherwise rewrite the
422    /// URI's own structure.
423    #[test]
424    fn a_hostile_username_is_percent_encoded_into_one_path_segment() {
425        let uri = provisioning_uri("ABCDEFGH", ISSUER, "a/b?c#d e");
426        let parsed = Url::parse(&uri).expect("the built URI must parse back");
427
428        let segments: Vec<&str> = parsed
429            .path_segments()
430            .expect("otpauth:// can be a base")
431            .collect();
432        assert_eq!(
433            segments.len(),
434            1,
435            "a `/` in the username must not become a second path segment: {uri}"
436        );
437
438        // The `?` and `#` must not have terminated the path either -- the query
439        // is exactly the five parameters this builds.
440        let names: Vec<String> = parsed
441            .query_pairs()
442            .map(|(name, _)| name.into_owned())
443            .collect();
444        assert_eq!(names, ["secret", "issuer", "algorithm", "digits", "period"]);
445    }
446
447    #[test]
448    fn begin_enrolment_agrees_with_itself() {
449        let enrolment = begin_enrolment(ISSUER, "alice@admin.example.com");
450        assert_eq!(enrolment.secret_base32, base32_encode(&enrolment.secret));
451        assert!(
452            enrolment
453                .uri
454                .contains(&format!("secret={}", enrolment.secret_base32)),
455            "the URI must carry the same secret the page shows: {}",
456            enrolment.uri
457        );
458
459        // And the whole thing round-trips: a code built from the raw secret
460        // verifies, which is the only assertion that proves the three
461        // representations are one secret.
462        let now = 1_700_000_000_i64;
463        let code = totp_at(&enrolment.secret, step_at(now), DIGITS);
464        assert!(verify(&enrolment.secret, &code, now).is_some());
465    }
466
467    #[test]
468    fn account_label_prefers_the_configured_host() {
469        assert_eq!(
470            account_label("alice", "https://admin.example.com:3001"),
471            "alice@admin.example.com"
472        );
473        assert_eq!(
474            account_label("alice", "http://localhost:3001"),
475            "alice@localhost"
476        );
477        // `check_config` refuses to start on either of these, so this is the
478        // belt to that braces -- a label, not a panic.
479        assert_eq!(account_label("alice", "not a url"), "alice");
480        assert_eq!(account_label("alice", ""), "alice");
481    }
482}