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}