Skip to main content

rustlavel_auth/
totp.rs

1//! Two-factor authentication: HOTP (RFC 4226) and TOTP (RFC 6238).
2//!
3//! The second factor every authenticator app speaks. The server and the phone
4//! share one secret; the phone shows a six-digit number derived from that secret
5//! and the current half-minute, and the server recomputes the same number. There
6//! is no network call and no vendor — the whole protocol is an HMAC and a
7//! division.
8//!
9//! ```ignore
10//! // Enrolment: mint a secret, show the QR code, and keep the secret only once
11//! // the person has proved they can read a code from it.
12//! let totp = Totp::generate();
13//! let uri = totp.provisioning_uri("jane@acme.test", "ACME");
14//! // ...render `uri` as a QR code, ask for a code, then:
15//! if totp.verify(&submitted, unix_now()) {
16//!     user.two_factor_secret = Some(totp.secret_base32());
17//!     user.recovery_codes = RecoveryCodes::generate(10).hashed();
18//! }
19//!
20//! // Login: rebuild it from the stored secret.
21//! let totp = Totp::from_base32(&user.two_factor_secret)?;
22//! if totp.verify(&submitted, unix_now()) { /* second factor satisfied */ }
23//! ```
24//!
25//! # What this protects against, and what it does not
26//!
27//! A TOTP code proves the person holds the shared secret *now*. It does not
28//! prove they are on the site they think they are on: a phishing page that asks
29//! for a code and replays it within thirty seconds gets in, and that is the
30//! attack passkeys exist to stop. TOTP raises the cost of a stolen password; it
31//! is not origin-bound and never has been.
32//!
33//! It also does nothing about replay on its own — see [`step_of`], which exists
34//! precisely so a caller can close that hole.
35
36use crate::hashing::{Cost, hash_password, hash_password_with, verify_password};
37use crate::random;
38use hmac::{Hmac, Mac};
39use rustlavel_core::{Error, Result};
40use rustlavel_http::url;
41use sha1::Sha1;
42use sha2::{Sha256, Sha512};
43use subtle::{Choice, ConstantTimeEq};
44
45/// The RFC 4648 base32 alphabet, uppercase, in the order the standard fixes it.
46pub const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
47
48/// How many random bytes a generated secret gets.
49///
50/// RFC 4226 §4 R6 requires at least 128 bits and recommends 160 — one full
51/// HMAC-SHA1 output — and 160 bits is what every provisioning implementation in
52/// the wild emits. A longer secret is not wrong, but HMAC folds anything past
53/// the block size back down through the hash, so it buys nothing an app would
54/// notice and it makes the QR code denser for no reason.
55pub const SECRET_BYTES: usize = 20;
56
57/// The default step, in seconds. RFC 6238 §5.2 recommends thirty.
58pub const DEFAULT_PERIOD: u64 = 30;
59
60/// The default number of digits shown. Six, as every app expects.
61pub const DEFAULT_DIGITS: u32 = 6;
62
63/// How many steps either side of the current one [`Totp::verify`] accepts.
64///
65/// One, which is the RFC 6238 §5.2 compromise: a phone whose clock is a few
66/// seconds off — or a person who starts typing at second 29 — must still be
67/// able to log in, and each extra step widens the window an attacker has to
68/// replay a code they observed.
69pub const DEFAULT_SKEW: u64 = 1;
70
71/// Encode bytes as unpadded uppercase base32 (RFC 4648 §6).
72///
73/// Unpadded because that is what `otpauth://` URIs and every authenticator app
74/// use: a trailing `=` in a QR code is noise that some scanners mangle, and the
75/// length of the secret already says where it ends.
76pub fn base32_encode(bytes: &[u8]) -> String {
77    let mut out = String::with_capacity(bytes.len().div_ceil(5) * 8);
78    let mut buffer: u32 = 0;
79    let mut bits: u32 = 0;
80
81    for byte in bytes {
82        buffer = (buffer << 8) | u32::from(*byte);
83        bits += 8;
84        while bits >= 5 {
85            bits -= 5;
86            out.push(BASE32_ALPHABET[((buffer >> bits) & 0x1f) as usize] as char);
87        }
88    }
89
90    // The last group is padded on the right with zero bits, never dropped: a
91    // secret whose length is not a multiple of five bytes still has to survive
92    // the round trip.
93    if bits > 0 {
94        out.push(BASE32_ALPHABET[((buffer << (5 - bits)) & 0x1f) as usize] as char);
95    }
96
97    out
98}
99
100/// Decode base32, the way a person actually types it.
101///
102/// Deliberately lenient about presentation and strict about content. Lowercase
103/// is accepted because keyboards default to it; spaces are ignored because
104/// every app that offers a manual-entry secret prints it in groups of four; `=`
105/// is ignored because some implementations pad and the padding carries no
106/// information. Anything else — a `0` typed for an `O`, a stray comma from a
107/// copy-and-paste — is rejected rather than silently reinterpreted, because
108/// guessing at a secret produces a working-looking `Totp` that never verifies.
109///
110/// Returns `None` for an unusable string; an empty input decodes to no bytes,
111/// which is what RFC 4648 §10 says and is [`Totp::from_base32`]'s problem to
112/// refuse, not this function's.
113pub fn base32_decode(text: &str) -> Option<Vec<u8>> {
114    let mut out = Vec::with_capacity(text.len() * 5 / 8);
115    let mut buffer: u32 = 0;
116    let mut bits: u32 = 0;
117
118    for character in text.chars() {
119        if character == '=' || character.is_whitespace() {
120            continue;
121        }
122
123        let value = match character {
124            'A'..='Z' => character as u32 - 'A' as u32,
125            'a'..='z' => character as u32 - 'a' as u32,
126            '2'..='7' => character as u32 - '2' as u32 + 26,
127            _ => return None,
128        };
129
130        buffer = (buffer << 5) | value;
131        bits += 5;
132        if bits >= 8 {
133            bits -= 8;
134            out.push((buffer >> bits) as u8);
135        }
136    }
137
138    // Fewer than eight bits are left over by construction, and they are the
139    // encoder's right-hand padding. They are dropped rather than checked for
140    // being zero: a secret copied out of an app that pads sloppily should still
141    // enrol, and the bits cannot change a byte that was already emitted.
142    Some(out)
143}
144
145/// Which HMAC an authenticator is asked to use.
146///
147/// SHA-1 is the default and, in practice, the only universally supported
148/// choice: RFC 6238 §1.2 allows all three, but an app handed
149/// `algorithm=SHA256` may quietly ignore it and compute SHA-1 anyway, which
150/// looks to the person like the server rejecting a correct code. Change this
151/// only when you control both ends.
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub enum Algorithm {
154    #[default]
155    Sha1,
156    Sha256,
157    Sha512,
158}
159
160impl Algorithm {
161    /// The name as an `otpauth://` URI spells it.
162    pub fn name(self) -> &'static str {
163        match self {
164            Algorithm::Sha1 => "SHA1",
165            Algorithm::Sha256 => "SHA256",
166            Algorithm::Sha512 => "SHA512",
167        }
168    }
169
170    /// Read a name back, accepting the spellings seen in the wild.
171    pub fn parse(name: &str) -> Option<Algorithm> {
172        match name.to_ascii_uppercase().replace('-', "").as_str() {
173            "SHA1" => Some(Algorithm::Sha1),
174            "SHA256" => Some(Algorithm::Sha256),
175            "SHA512" => Some(Algorithm::Sha512),
176            _ => None,
177        }
178    }
179}
180
181impl std::fmt::Display for Algorithm {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        f.write_str(self.name())
184    }
185}
186
187/// HMAC the eight-byte big-endian counter under `secret`.
188///
189/// Written out three times rather than made generic. The bound that would let
190/// one function accept any of the three digests is a dozen lines of
191/// `digest::core_api` associated types, and the compiler error a caller gets
192/// when it does not hold is unreadable — which is a worse trade than three
193/// obvious lines.
194fn mac(algorithm: Algorithm, secret: &[u8], counter: u64) -> Vec<u8> {
195    let message = counter.to_be_bytes();
196
197    match algorithm {
198        Algorithm::Sha1 => {
199            let mut mac =
200                Hmac::<Sha1>::new_from_slice(secret).expect("HMAC accepts a key of any length");
201            mac.update(&message);
202            mac.finalize().into_bytes().to_vec()
203        }
204        Algorithm::Sha256 => {
205            let mut mac =
206                Hmac::<Sha256>::new_from_slice(secret).expect("HMAC accepts a key of any length");
207            mac.update(&message);
208            mac.finalize().into_bytes().to_vec()
209        }
210        Algorithm::Sha512 => {
211            let mut mac =
212                Hmac::<Sha512>::new_from_slice(secret).expect("HMAC accepts a key of any length");
213            mac.update(&message);
214            mac.finalize().into_bytes().to_vec()
215        }
216    }
217}
218
219/// Clamp a digit count to what RFC 4226 §5.3 allows.
220///
221/// The RFC's floor is six — fewer digits means a one-in-ten-thousand guess,
222/// which is inside what an unthrottled login endpoint hands out for free. The
223/// ceiling of ten is arithmetic rather than policy: dynamic truncation yields a
224/// 31-bit number, so an eleventh digit would always be a zero.
225fn clamped_digits(digits: u32) -> u32 {
226    digits.clamp(6, 10)
227}
228
229/// One HOTP value: RFC 4226's counter-based code.
230///
231/// `counter` is a shared, monotonically increasing number rather than a clock —
232/// this is the algorithm TOTP is built out of, and it is what a hardware token
233/// with a button uses. `digits` is clamped to the RFC's six-to-ten range; see
234/// [`clamped_digits`].
235///
236/// Always SHA-1, because that is the only algorithm RFC 4226 defines. Use
237/// [`hotp_with`] to pick another.
238pub fn hotp(secret: &[u8], counter: u64, digits: u32) -> String {
239    hotp_with(Algorithm::Sha1, secret, counter, digits)
240}
241
242/// [`hotp`], with the HMAC named explicitly. This is what TOTP calls.
243pub fn hotp_with(algorithm: Algorithm, secret: &[u8], counter: u64, digits: u32) -> String {
244    let digits = clamped_digits(digits);
245    let hash = mac(algorithm, secret, counter);
246
247    // Dynamic truncation, RFC 4226 §5.3. The low nibble of the last byte picks
248    // where in the digest to read from, so which four bytes carry the code is
249    // itself a function of the secret; the top bit is masked off because some
250    // platforms have no unsigned 32-bit type and would read the result as a
251    // negative number.
252    let offset = (hash[hash.len() - 1] & 0x0f) as usize;
253    let binary = (u32::from(hash[offset]) & 0x7f) << 24
254        | u32::from(hash[offset + 1]) << 16
255        | u32::from(hash[offset + 2]) << 8
256        | u32::from(hash[offset + 3]);
257
258    let value = u64::from(binary) % 10u64.pow(digits);
259    format!("{value:0width$}", width = digits as usize)
260}
261
262/// Which time step `unix_seconds` falls in — RFC 6238's `T`.
263///
264/// # This is how you stop a code being replayed
265///
266/// TOTP by itself does not. A code stays valid for the whole step, and for the
267/// neighbouring steps too once a skew window is allowed, so anybody who reads
268/// one over a shoulder, out of a phishing form, or off a shared screen can use
269/// it again for the best part of a minute — and [`Totp::verify`] will accept it,
270/// because it is a correct code.
271///
272/// The fix is not in this module, because it needs storage this module does not
273/// have. Keep the last step you accepted for each user, alongside their secret,
274/// and refuse anything that is not strictly newer:
275///
276/// ```ignore
277/// let step = step_of(unix_now(), totp.period());
278/// if user.last_totp_step.is_some_and(|last| step <= last) {
279///     return Err("that code has already been used");
280/// }
281/// if totp.verify(&submitted, unix_now()) {
282///     user.last_totp_step = Some(step);
283/// }
284/// ```
285///
286/// A `period` of zero is read as one second rather than dividing by zero.
287pub fn step_of(unix_seconds: u64, period: u64) -> u64 {
288    unix_seconds / period.max(1)
289}
290
291/// A TOTP configuration: one secret and the parameters an app must agree on.
292///
293/// Built with [`generate`](Self::generate) at enrolment and with
294/// [`from_base32`](Self::from_base32) on every later login. The defaults —
295/// twenty random bytes, six digits, thirty seconds, HMAC-SHA1 — are what an
296/// authenticator app assumes when a provisioning URI leaves them out, so
297/// changing one means the app and the server must both be told.
298#[derive(Clone)]
299pub struct Totp {
300    secret: Vec<u8>,
301    digits: u32,
302    period: u64,
303    algorithm: Algorithm,
304    skew: u64,
305}
306
307impl Totp {
308    /// A new random secret, from the operating system's CSPRNG.
309    ///
310    /// This is a credential: whoever holds it can produce codes forever. It is
311    /// generated once, shown once as a QR code, and stored encrypted.
312    pub fn generate() -> Self {
313        Totp::new(random::bytes(SECRET_BYTES))
314    }
315
316    /// Wrap an existing secret, with every default in place.
317    pub fn new(secret: Vec<u8>) -> Self {
318        Totp {
319            secret,
320            digits: DEFAULT_DIGITS,
321            period: DEFAULT_PERIOD,
322            algorithm: Algorithm::default(),
323            skew: DEFAULT_SKEW,
324        }
325    }
326
327    /// Rebuild from the base32 secret as it was stored or typed.
328    ///
329    /// An empty secret is an error rather than a `Totp` over zero bytes. HMAC
330    /// accepts an empty key quite happily, so the mistake would not surface
331    /// here — it would surface as every user with a blank column sharing the
332    /// same predictable codes.
333    pub fn from_base32(secret: &str) -> Result<Self> {
334        let bytes = base32_decode(secret).ok_or_else(|| {
335            Error::msg("that is not a base32 secret: it contains characters outside A-Z and 2-7")
336        })?;
337
338        if bytes.is_empty() {
339            return Err(Error::msg("a TOTP secret cannot be empty"));
340        }
341
342        Ok(Totp::new(bytes))
343    }
344
345    /// How many digits the code has. Clamped to RFC 4226's six-to-ten range.
346    pub fn with_digits(mut self, digits: u32) -> Self {
347        self.digits = clamped_digits(digits);
348        self
349    }
350
351    /// How long one code lasts, in seconds. Zero is read as one.
352    pub fn with_period(mut self, period: u64) -> Self {
353        self.period = period.max(1);
354        self
355    }
356
357    pub fn with_algorithm(mut self, algorithm: Algorithm) -> Self {
358        self.algorithm = algorithm;
359        self
360    }
361
362    /// How many steps either side of now [`verify`](Self::verify) accepts.
363    ///
364    /// Zero demands a perfectly synchronised phone and will lock people out.
365    /// Anything above two is a replay window measured in minutes.
366    pub fn with_skew(mut self, skew: u64) -> Self {
367        self.skew = skew;
368        self
369    }
370
371    /// The raw secret. As sensitive as a password hash and then some — this one
372    /// is the credential itself, not a digest of it.
373    pub fn secret(&self) -> &[u8] {
374        &self.secret
375    }
376
377    /// The secret as an authenticator app wants to see it.
378    pub fn secret_base32(&self) -> String {
379        base32_encode(&self.secret)
380    }
381
382    pub fn digits(&self) -> u32 {
383        self.digits
384    }
385
386    pub fn period(&self) -> u64 {
387        self.period
388    }
389
390    pub fn algorithm(&self) -> Algorithm {
391        self.algorithm
392    }
393
394    pub fn skew(&self) -> u64 {
395        self.skew
396    }
397
398    /// The code for a given unix timestamp, in seconds.
399    pub fn at(&self, unix_seconds: u64) -> String {
400        self.at_step(step_of(unix_seconds, self.period))
401    }
402
403    /// The code for a step number directly — RFC 6238's `T`.
404    pub fn at_step(&self, step: u64) -> String {
405        hotp_with(self.algorithm, &self.secret, step, self.digits)
406    }
407
408    /// The code right now.
409    pub fn now(&self) -> String {
410        self.at(crate::unix_now())
411    }
412
413    /// Whether `code` is one this secret produced around `unix_seconds`.
414    ///
415    /// Three things are going on here, and each is deliberate.
416    ///
417    /// **The comparison is constant time.** A code is only six digits, so an
418    /// attacker who can measure how long a rejection took can walk them one
419    /// position at a time and be through a million-code space in sixty guesses
420    /// instead of a million. Every candidate is compared with `subtle`'s
421    /// branch-free equality and the results are OR-ed together, so the loop
422    /// always runs to the end: returning early on the first match would leak
423    /// *which* step matched, which is the phone's clock offset.
424    ///
425    /// **A window of ±[`skew`](Self::skew) steps is accepted.** Phone clocks
426    /// drift, networks add latency, and people start typing at second 29. With
427    /// no window a noticeable fraction of honest logins fail, and the support
428    /// answer — turn the second factor off — is worse than the widened replay
429    /// window. One step either side is RFC 6238 §5.2's recommendation.
430    ///
431    /// **A malformed code is rejected up front.** The length and the digit
432    /// check both short-circuit, and that leaks nothing: how many digits this
433    /// configuration uses is printed in the provisioning URI and shown on the
434    /// person's phone. What must not vary with timing is the comparison against
435    /// the *value*, and that happens below, on inputs already known to be the
436    /// right shape.
437    ///
438    /// It does not, and cannot, tell you whether this code was used a moment
439    /// ago. See [`step_of`].
440    pub fn verify(&self, code: &str, unix_seconds: u64) -> bool {
441        if code.len() != self.digits as usize || !code.bytes().all(|byte| byte.is_ascii_digit()) {
442            return false;
443        }
444
445        let step = step_of(unix_seconds, self.period);
446        let first = step.saturating_sub(self.skew);
447        let last = step.saturating_add(self.skew);
448
449        let mut matched = Choice::from(0u8);
450        for candidate in first..=last {
451            matched |= self.at_step(candidate).as_bytes().ct_eq(code.as_bytes());
452        }
453
454        matched.into()
455    }
456
457    /// [`verify`](Self::verify) against the system clock.
458    pub fn verify_now(&self, code: &str) -> bool {
459        self.verify(code, crate::unix_now())
460    }
461
462    /// The `otpauth://` URI an authenticator app scans.
463    ///
464    /// The label is `{issuer}:{account}`, which is what the Key Uri Format
465    /// specifies and what makes an app show "ACME (jane@acme.test)" rather than
466    /// a bare address. Both halves are percent-encoded *separately* and then
467    /// joined with a literal colon, so a colon inside the issuer or the account
468    /// arrives as `%3A` and cannot be mistaken for the separator — an account
469    /// name is user-controlled, and a user who can move the separator can make
470    /// their entry impersonate another issuer in the app's list.
471    ///
472    /// The URI carries the secret in the clear. It belongs in a QR code on a
473    /// page served over TLS, never in a log, an analytics event or a URL a
474    /// server records.
475    pub fn provisioning_uri(&self, account: &str, issuer: &str) -> String {
476        format!(
477            "otpauth://totp/{}:{}?secret={}&issuer={}&algorithm={}&digits={}&period={}",
478            url::encode(issuer),
479            url::encode(account),
480            self.secret_base32(),
481            url::encode(issuer),
482            self.algorithm.name(),
483            self.digits,
484            self.period,
485        )
486    }
487}
488
489/// Redacted by hand. The secret is the whole second factor, and `{:?}` on a
490/// user record is exactly how it would reach a log file.
491impl std::fmt::Debug for Totp {
492    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
493        f.debug_struct("Totp")
494            .field("secret", &"<redacted>")
495            .field("digits", &self.digits)
496            .field("period", &self.period)
497            .field("algorithm", &self.algorithm)
498            .field("skew", &self.skew)
499            .finish()
500    }
501}
502
503/// The alphabet recovery codes are drawn from.
504///
505/// Thirty-two characters, which is a power of two, so a random byte masked to
506/// five bits picks one with no modulo bias. `0`, `1`, `O` and `I` are absent:
507/// these codes are printed on paper and read back by somebody who has just lost
508/// their phone and is not in a mood to tell an oh from a nought. Only uppercase
509/// is used, which is what keeps `L` in — it is a lowercase `l` that gets
510/// mistaken for a one, and [`normalise`] uppercases before it compares.
511///
512/// Dropping a fifth character would leave thirty-one, and a 31-symbol alphabet
513/// cannot be sampled from a byte without either bias or a rejection loop.
514pub const RECOVERY_ALPHABET: &[u8; 32] = b"23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
515
516/// How many characters a recovery code carries, before formatting.
517///
518/// Ten characters from a 32-symbol alphabet is fifty bits — far past anything
519/// guessable, and short enough to type twice without losing patience.
520pub const RECOVERY_CODE_LENGTH: usize = 10;
521
522/// A freshly generated set of recovery codes, in the clear.
523///
524/// The one moment they exist readably. Show them once, tell the person to print
525/// them, store only [`hashed`](Self::hashed) — exactly like a password, and for
526/// the same reason: each one is a complete bypass of the second factor.
527#[derive(Clone)]
528pub struct RecoveryCodes {
529    codes: Vec<String>,
530}
531
532impl RecoveryCodes {
533    /// Mint `count` codes. Ten is the usual number, and what Laravel issues.
534    pub fn generate(count: usize) -> RecoveryCodes {
535        // One byte per character, drawn in a single read: five bits of each is
536        // used and the rest discarded, which is cheaper than being clever and
537        // cannot introduce the bias that `% 32` over a non-power-of-two
538        // alphabet would.
539        let bytes = random::bytes(count * RECOVERY_CODE_LENGTH);
540
541        let codes = bytes
542            .chunks(RECOVERY_CODE_LENGTH)
543            .map(|chunk| {
544                let characters: String = chunk
545                    .iter()
546                    .map(|byte| RECOVERY_ALPHABET[(byte & 0x1f) as usize] as char)
547                    .collect();
548                punctuate(&characters)
549            })
550            .collect();
551
552        RecoveryCodes { codes }
553    }
554
555    /// The codes, to show once and never again.
556    pub fn codes(&self) -> &[String] {
557        &self.codes
558    }
559
560    pub fn into_codes(self) -> Vec<String> {
561        self.codes
562    }
563
564    /// What to store: an argon2 hash of each code.
565    ///
566    /// Argon2 rather than SHA-256, unlike [`crate::tokens`], and the difference
567    /// is deliberate. An API token is 256 bits of CSPRNG output, so there is
568    /// nothing to search; a recovery code is fifty bits *and is typed by a
569    /// person*, which means it may well be written down, photographed, or
570    /// entered into the wrong box. Fifty bits is beyond an online attacker but
571    /// it is not beyond somebody who has stolen the table, and a KDF is what
572    /// makes the difference. They are also verified once, on a rare path, so
573    /// the cost buys the protection without being felt.
574    pub fn hashed(&self) -> Vec<String> {
575        self.hashed_with(Cost::DEFAULT)
576    }
577
578    /// [`hashed`](Self::hashed) at an explicit cost, for tests. See
579    /// [`Cost::FAST`], and do not store real codes hashed with it.
580    pub fn hashed_with(&self, cost: Cost) -> Vec<String> {
581        self.codes
582            .iter()
583            .map(|code| {
584                hash_password_with(code, cost)
585                    .expect("argon2 accepts every Cost this crate can construct")
586            })
587            .collect()
588    }
589}
590
591/// Redacted, for the reason [`Totp`]'s is.
592impl std::fmt::Debug for RecoveryCodes {
593    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
594        f.debug_struct("RecoveryCodes")
595            .field("count", &self.codes.len())
596            .field("codes", &"<redacted>")
597            .finish()
598    }
599}
600
601/// Hash a single recovery code the way [`RecoveryCodes::hashed`] does.
602///
603/// Useful when codes are stored one row at a time rather than as a set.
604pub fn hash_recovery_code(code: &str) -> Result<String> {
605    hash_password(&normalise(code))
606}
607
608/// Punctuate ten characters as `xxxxx-xxxxx`.
609///
610/// Purely so the eye can find its place halfway through. The dash is not part
611/// of the code — [`normalise`] throws it away again — so somebody who types the
612/// ten characters straight through is not turned away.
613fn punctuate(characters: &str) -> String {
614    if characters.len() != RECOVERY_CODE_LENGTH {
615        return characters.to_string();
616    }
617    format!("{}-{}", &characters[..5], &characters[5..])
618}
619
620/// Reduce a typed code to the form that was hashed.
621///
622/// Uppercased, with everything outside the alphabet dropped, then re-punctuated.
623/// That absorbs the dash, the spaces a phone keyboard inserts, and the
624/// lowercase a person types — none of which carry information, and all of which
625/// would otherwise reject a correct code at the worst possible moment.
626fn normalise(code: &str) -> String {
627    let characters: String = code
628        .chars()
629        .map(|character| character.to_ascii_uppercase())
630        .filter(|character| {
631            character.is_ascii() && RECOVERY_ALPHABET.contains(&(*character as u8))
632        })
633        .collect();
634
635    punctuate(&characters)
636}
637
638/// Spend a recovery code: verify it, remove the hash it matched, and say so.
639///
640/// One-time by construction. `hashes` is the stored list and it is edited in
641/// place, so the caller's next step is to persist it — a code that is accepted
642/// and left in the list is a permanent second-factor bypass, which is the whole
643/// thing this function exists to prevent.
644///
645/// Not constant time across the list, and it does not need to be: fifty bits of
646/// entropy means an attacker cannot get close enough for the timing of *which*
647/// hash matched to be worth anything. What it does cost is one argon2
648/// verification per stored hash on a miss, so this path must be rate-limited
649/// like a login — ten hashes at the default cost is most of half a second of
650/// CPU per attempt.
651pub fn consume_recovery_code(code: &str, hashes: &mut Vec<String>) -> bool {
652    let normalised = normalise(code);
653    if normalised.is_empty() {
654        return false;
655    }
656
657    let Some(position) = hashes.iter().position(|hash| verify_password(&normalised, hash)) else {
658        return false;
659    };
660
661    hashes.remove(position);
662    true
663}
664
665#[cfg(test)]
666mod tests {
667    use super::*;
668
669    /// RFC 4226 Appendix D and the SHA-1 rows of RFC 6238 Appendix B.
670    const RFC_SECRET_SHA1: &[u8] = b"12345678901234567890";
671    /// RFC 6238 Appendix B, SHA-256 rows: the seed is extended to 32 bytes.
672    const RFC_SECRET_SHA256: &[u8] = b"12345678901234567890123456789012";
673    /// RFC 6238 Appendix B, SHA-512 rows: extended to 64 bytes.
674    const RFC_SECRET_SHA512: &[u8] =
675        b"1234567890123456789012345678901234567890123456789012345678901234";
676
677    #[test]
678    fn base32_matches_the_rfc_4648_test_vectors() {
679        // RFC 4648 §10, minus the padding this encoder does not emit.
680        let vectors = [
681            ("", ""),
682            ("f", "MY"),
683            ("fo", "MZXQ"),
684            ("foo", "MZXW6"),
685            ("foob", "MZXW6YQ"),
686            ("fooba", "MZXW6YTB"),
687            ("foobar", "MZXW6YTBOI"),
688        ];
689
690        for (plain, encoded) in vectors {
691            assert_eq!(base32_encode(plain.as_bytes()), encoded, "encoding {plain:?}");
692            assert_eq!(
693                base32_decode(encoded).as_deref(),
694                Some(plain.as_bytes()),
695                "decoding {encoded:?}"
696            );
697        }
698    }
699
700    #[test]
701    fn base32_round_trips_every_length_up_to_a_full_group() {
702        for length in 0..=32 {
703            let bytes: Vec<u8> = (0..length).map(|i| i as u8 ^ 0xa5).collect();
704            let encoded = base32_encode(&bytes);
705
706            assert!(encoded.bytes().all(|b| BASE32_ALPHABET.contains(&b)), "{encoded}");
707            assert_eq!(base32_decode(&encoded), Some(bytes), "at length {length}");
708        }
709    }
710
711    #[test]
712    fn decoding_forgives_case_spaces_and_padding() {
713        let expected = Some(b"foobar".to_vec());
714
715        assert_eq!(base32_decode("mzxw6ytboi"), expected);
716        assert_eq!(base32_decode("MZXW 6YTB OI"), expected);
717        assert_eq!(base32_decode("mzxw 6ytb OI"), expected);
718        assert_eq!(base32_decode("MZXW6YTBOI======"), expected);
719        assert_eq!(base32_decode("MZXW6YTB OI==\n"), expected);
720    }
721
722    #[test]
723    fn decoding_rejects_anything_outside_the_alphabet() {
724        // `0`, `1` and `8` are the classic mistypes, and each has to be an
725        // error rather than a quietly different secret.
726        for text in ["MZXW6YTB0I", "MZXW6YTB1I", "MZXW6YTB8I", "MZXW-6YTB", "MZXW6YTB!", "héllo"] {
727            assert_eq!(base32_decode(text), None, "{text:?} should not decode");
728        }
729    }
730
731    #[test]
732    fn hotp_reproduces_the_whole_rfc_4226_appendix_d_table() {
733        let expected = [
734            "755224", "287082", "359152", "969429", "338314", "254676", "287922", "162583",
735            "399871", "520489",
736        ];
737
738        for (counter, code) in expected.iter().enumerate() {
739            assert_eq!(
740                &hotp(RFC_SECRET_SHA1, counter as u64, 6),
741                code,
742                "RFC 4226 counter {counter}"
743            );
744        }
745    }
746
747    #[test]
748    fn totp_reproduces_the_rfc_6238_appendix_b_table_for_sha1() {
749        // The RFC prints the table with one seed for every row, but the values
750        // were computed with a seed per algorithm — errata 2866. These are the
751        // 20-byte seed's rows.
752        let vectors = [
753            (59u64, "94287082"),
754            (1_111_111_109, "07081804"),
755            (1_111_111_111, "14050471"),
756            (1_234_567_890, "89005924"),
757            (2_000_000_000, "69279037"),
758            (20_000_000_000, "65353130"),
759        ];
760
761        let totp = Totp::new(RFC_SECRET_SHA1.to_vec()).with_digits(8);
762        for (at, code) in vectors {
763            assert_eq!(totp.at(at), code, "RFC 6238 SHA-1 at T={at}");
764        }
765    }
766
767    #[test]
768    fn totp_reproduces_the_rfc_6238_appendix_b_table_for_sha256() {
769        let vectors = [
770            (59u64, "46119246"),
771            (1_111_111_109, "68084774"),
772            (1_111_111_111, "67062674"),
773            (1_234_567_890, "91819424"),
774            (2_000_000_000, "90698825"),
775            (20_000_000_000, "77737706"),
776        ];
777
778        let totp = Totp::new(RFC_SECRET_SHA256.to_vec())
779            .with_digits(8)
780            .with_algorithm(Algorithm::Sha256);
781        for (at, code) in vectors {
782            assert_eq!(totp.at(at), code, "RFC 6238 SHA-256 at T={at}");
783        }
784    }
785
786    #[test]
787    fn totp_reproduces_the_rfc_6238_appendix_b_table_for_sha512() {
788        let vectors = [
789            (59u64, "90693936"),
790            (1_111_111_109, "25091201"),
791            (1_111_111_111, "99943326"),
792            (1_234_567_890, "93441116"),
793            (2_000_000_000, "38618901"),
794            (20_000_000_000, "47863826"),
795        ];
796
797        let totp = Totp::new(RFC_SECRET_SHA512.to_vec())
798            .with_digits(8)
799            .with_algorithm(Algorithm::Sha512);
800        for (at, code) in vectors {
801            assert_eq!(totp.at(at), code, "RFC 6238 SHA-512 at T={at}");
802        }
803    }
804
805    #[test]
806    fn the_rfc_6238_times_land_on_the_steps_the_rfc_prints() {
807        // Appendix B's "T (hex)" column, which is the one number the whole
808        // algorithm hangs on.
809        assert_eq!(step_of(59, 30), 0x1);
810        assert_eq!(step_of(1_111_111_109, 30), 0x23523EC);
811        assert_eq!(step_of(1_111_111_111, 30), 0x23523ED);
812        assert_eq!(step_of(1_234_567_890, 30), 0x273EF07);
813        assert_eq!(step_of(2_000_000_000, 30), 0x3F940AA);
814        assert_eq!(step_of(20_000_000_000, 30), 0x27BC86AA);
815    }
816
817    #[test]
818    fn a_step_is_the_timestamp_divided_by_the_period() {
819        assert_eq!(step_of(0, 30), 0);
820        assert_eq!(step_of(29, 30), 0);
821        assert_eq!(step_of(30, 30), 1);
822        assert_eq!(step_of(61, 60), 1);
823
824        // A zero period is read as one second rather than dividing by zero.
825        assert_eq!(step_of(7, 0), 7);
826    }
827
828    #[test]
829    fn the_default_shape_is_six_digits_over_thirty_seconds_of_sha1() {
830        let totp = Totp::generate();
831
832        assert_eq!(totp.digits(), 6);
833        assert_eq!(totp.period(), 30);
834        assert_eq!(totp.algorithm(), Algorithm::Sha1);
835        assert_eq!(totp.skew(), 1);
836        assert_eq!(totp.secret().len(), SECRET_BYTES);
837        assert_eq!(totp.now().len(), 6);
838        assert!(totp.now().bytes().all(|b| b.is_ascii_digit()));
839    }
840
841    #[test]
842    fn two_generated_secrets_differ() {
843        assert_ne!(Totp::generate().secret(), Totp::generate().secret());
844    }
845
846    #[test]
847    fn a_secret_survives_the_base32_round_trip() {
848        let totp = Totp::generate();
849        let encoded = totp.secret_base32();
850
851        assert_eq!(encoded.len(), 32, "20 bytes is 32 base32 characters");
852        let rebuilt = Totp::from_base32(&encoded).expect("its own output should parse");
853
854        assert_eq!(rebuilt.secret(), totp.secret());
855        assert_eq!(rebuilt.at(1_234_567_890), totp.at(1_234_567_890));
856    }
857
858    #[test]
859    fn an_empty_or_malformed_secret_is_refused_rather_than_accepted_as_zero_bytes() {
860        assert!(Totp::from_base32("").is_err());
861        assert!(Totp::from_base32("   ").is_err());
862        assert!(Totp::from_base32("====").is_err());
863        assert!(Totp::from_base32("MZXW6YTB0I").is_err());
864
865        assert!(Totp::from_base32("mzxw 6ytb oi").is_ok(), "leniency still applies");
866    }
867
868    #[test]
869    fn the_current_code_verifies() {
870        let totp = Totp::generate();
871        let now = crate::unix_now();
872
873        assert!(totp.verify(&totp.at(now), now));
874        assert!(totp.verify_now(&totp.now()));
875    }
876
877    #[test]
878    fn one_step_either_side_is_accepted_and_two_are_not() {
879        let totp = Totp::new(RFC_SECRET_SHA1.to_vec());
880        let now = 1_234_567_890;
881
882        assert!(totp.verify(&totp.at(now - 30), now), "a code from the previous step");
883        assert!(totp.verify(&totp.at(now), now));
884        assert!(totp.verify(&totp.at(now + 30), now), "and one from the next");
885
886        assert!(!totp.verify(&totp.at(now - 60), now), "two steps back is outside the window");
887        assert!(!totp.verify(&totp.at(now + 60), now));
888    }
889
890    #[test]
891    fn the_window_is_configurable_in_both_directions() {
892        let secret = RFC_SECRET_SHA1.to_vec();
893        let now = 1_234_567_890;
894
895        let strict = Totp::new(secret.clone()).with_skew(0);
896        assert!(strict.verify(&strict.at(now), now));
897        assert!(!strict.verify(&strict.at(now - 30), now), "no window means no drift allowed");
898
899        let generous = Totp::new(secret).with_skew(2);
900        assert!(generous.verify(&generous.at(now - 60), now));
901        assert!(!generous.verify(&generous.at(now - 90), now));
902    }
903
904    #[test]
905    fn the_window_does_not_underflow_at_the_epoch() {
906        // A clock that has not been set yet reports a timestamp near zero, and
907        // `step - skew` must not wrap around to the far end of u64.
908        let totp = Totp::new(RFC_SECRET_SHA1.to_vec()).with_skew(5);
909
910        assert!(totp.verify(&totp.at(0), 0));
911        assert!(!totp.verify("000000", u64::MAX));
912    }
913
914    #[test]
915    fn a_code_from_another_secret_is_rejected() {
916        let mine = Totp::generate();
917        let yours = Totp::generate();
918        let now = crate::unix_now();
919
920        assert!(!mine.verify(&yours.at(now), now));
921        assert!(!yours.verify(&mine.at(now), now));
922    }
923
924    #[test]
925    fn a_code_of_the_wrong_shape_is_rejected_without_panicking() {
926        let totp = Totp::generate();
927        let now = crate::unix_now();
928        let real = totp.at(now);
929        let too_short = real[..5].to_string();
930        let too_long = format!("{real}0");
931        let padded = format!(" {real}");
932
933        for code in [
934            "",
935            "12345",
936            "1234567",
937            "12345 ",
938            " 123456",
939            "abcdef",
940            "12345a",
941            "12-345",
942            // Arabic-Indic digits: `is_ascii_digit` says no, and it must.
943            "١٢٣٤٥٦",
944            too_short.as_str(),
945            too_long.as_str(),
946            padded.as_str(),
947        ] {
948            assert!(!totp.verify(code, now), "{code:?} should not verify");
949        }
950
951        // And the real one still does, so the test is not passing by refusing
952        // everything.
953        assert!(totp.verify(&real, now));
954    }
955
956    #[test]
957    fn a_different_algorithm_produces_a_different_code_from_the_same_secret() {
958        let secret = RFC_SECRET_SHA1.to_vec();
959        let at = 1_234_567_890;
960
961        let sha1 = Totp::new(secret.clone()).with_digits(8);
962        let sha256 = Totp::new(secret.clone()).with_digits(8).with_algorithm(Algorithm::Sha256);
963        let sha512 = Totp::new(secret).with_digits(8).with_algorithm(Algorithm::Sha512);
964
965        assert_ne!(sha1.at(at), sha256.at(at));
966        assert_ne!(sha256.at(at), sha512.at(at));
967        assert!(!sha1.verify(&sha256.at(at), at), "the algorithm has to match on both ends");
968    }
969
970    #[test]
971    fn digits_are_clamped_to_the_range_rfc_4226_allows() {
972        let secret = RFC_SECRET_SHA1.to_vec();
973
974        assert_eq!(Totp::new(secret.clone()).with_digits(0).digits(), 6);
975        assert_eq!(Totp::new(secret.clone()).with_digits(4).digits(), 6);
976        assert_eq!(Totp::new(secret.clone()).with_digits(8).digits(), 8);
977        assert_eq!(Totp::new(secret.clone()).with_digits(99).digits(), 10);
978
979        assert_eq!(hotp(RFC_SECRET_SHA1, 0, 0).len(), 6);
980        assert_eq!(hotp(RFC_SECRET_SHA1, 0, 8), "84755224", "the RFC's 8-digit form of row 0");
981        assert_eq!(hotp(RFC_SECRET_SHA1, 0, 99).len(), 10);
982    }
983
984    #[test]
985    fn a_period_of_zero_is_read_as_one_second() {
986        let totp = Totp::new(RFC_SECRET_SHA1.to_vec()).with_period(0);
987
988        assert_eq!(totp.period(), 1);
989        assert!(totp.verify(&totp.at(1_000), 1_000));
990    }
991
992    #[test]
993    fn the_provisioning_uri_carries_every_parameter() {
994        let totp = Totp::new(b"foobar".to_vec());
995        let uri = totp.provisioning_uri("jane@acme.test", "ACME");
996
997        assert_eq!(
998            uri,
999            "otpauth://totp/ACME:jane%40acme.test\
1000             ?secret=MZXW6YTBOI&issuer=ACME&algorithm=SHA1&digits=6&period=30"
1001        );
1002    }
1003
1004    #[test]
1005    fn a_label_with_a_space_or_a_colon_is_percent_encoded() {
1006        let totp = Totp::new(b"foobar".to_vec())
1007            .with_digits(8)
1008            .with_period(60)
1009            .with_algorithm(Algorithm::Sha512);
1010
1011        let uri = totp.provisioning_uri("jane doe:admin", "ACME Corp");
1012
1013        // The separating colon is the only literal one; the account's own colon
1014        // is escaped, so it cannot pose as the issuer boundary.
1015        assert!(uri.starts_with("otpauth://totp/ACME%20Corp:jane%20doe%3Aadmin?"), "{uri}");
1016        assert_eq!(uri.matches(':').count(), 2, "one in the scheme, one separating the label");
1017
1018        assert!(uri.contains("&issuer=ACME%20Corp"));
1019        assert!(uri.contains("&algorithm=SHA512"));
1020        assert!(uri.contains("&digits=8"));
1021        assert!(uri.contains("&period=60"));
1022    }
1023
1024    #[test]
1025    fn algorithm_names_round_trip() {
1026        for algorithm in [Algorithm::Sha1, Algorithm::Sha256, Algorithm::Sha512] {
1027            assert_eq!(Algorithm::parse(algorithm.name()), Some(algorithm));
1028            assert_eq!(algorithm.to_string(), algorithm.name());
1029        }
1030
1031        assert_eq!(Algorithm::parse("sha1"), Some(Algorithm::Sha1));
1032        assert_eq!(Algorithm::parse("SHA-256"), Some(Algorithm::Sha256));
1033        assert_eq!(Algorithm::parse("md5"), None);
1034        assert_eq!(Algorithm::parse(""), None);
1035    }
1036
1037    #[test]
1038    fn a_secret_never_appears_in_debug_output() {
1039        let totp = Totp::generate();
1040        let printed = format!("{totp:?}");
1041
1042        assert!(!printed.contains(&totp.secret_base32()), "the secret escaped into {printed}");
1043        assert!(printed.contains("<redacted>"));
1044        // The parameters are still there, or the redaction is too blunt to help.
1045        assert!(printed.contains("digits: 6"));
1046        assert!(printed.contains("Sha1"));
1047    }
1048
1049    #[test]
1050    fn recovery_codes_are_typeable_and_unambiguous() {
1051        let codes = RecoveryCodes::generate(10);
1052
1053        assert_eq!(codes.codes().len(), 10);
1054        for code in codes.codes() {
1055            assert_eq!(code.len(), RECOVERY_CODE_LENGTH + 1, "{code} should be xxxxx-xxxxx");
1056            assert_eq!(&code[5..6], "-");
1057
1058            for character in code.chars().filter(|c| *c != '-') {
1059                assert!(RECOVERY_ALPHABET.contains(&(character as u8)), "{code} contains {character}");
1060            }
1061            for confusable in ['0', 'O', '1', 'I', 'l'] {
1062                assert!(!code.contains(confusable), "{code} contains a {confusable}");
1063            }
1064        }
1065    }
1066
1067    #[test]
1068    fn every_generated_code_is_distinct() {
1069        let codes = RecoveryCodes::generate(50);
1070        let mut seen = codes.codes().to_vec();
1071        seen.sort();
1072        seen.dedup();
1073
1074        assert_eq!(seen.len(), 50, "50 bits of entropy should not collide");
1075        assert_ne!(RecoveryCodes::generate(1).codes(), RecoveryCodes::generate(1).codes());
1076    }
1077
1078    #[test]
1079    fn a_recovery_code_works_exactly_once() {
1080        let codes = RecoveryCodes::generate(3);
1081        let mut hashes = codes.hashed_with(Cost::FAST);
1082        let spent = codes.codes()[1].clone();
1083
1084        assert!(consume_recovery_code(&spent, &mut hashes), "the first use should be accepted");
1085        assert_eq!(hashes.len(), 2, "the hash it matched must be gone");
1086
1087        assert!(!consume_recovery_code(&spent, &mut hashes), "the second use must not be");
1088        assert_eq!(hashes.len(), 2);
1089
1090        // The others are untouched.
1091        assert!(consume_recovery_code(&codes.codes()[0], &mut hashes));
1092        assert!(consume_recovery_code(&codes.codes()[2], &mut hashes));
1093        assert!(hashes.is_empty());
1094    }
1095
1096    #[test]
1097    fn an_unknown_code_is_refused_and_removes_nothing() {
1098        let codes = RecoveryCodes::generate(3);
1099        let mut hashes = codes.hashed_with(Cost::FAST);
1100
1101        for guess in ["", "-", "22222-22222", "not a code", &codes.codes()[0].replace('-', "X")] {
1102            assert!(!consume_recovery_code(guess, &mut hashes), "{guess:?} should not be accepted");
1103        }
1104        assert_eq!(hashes.len(), 3);
1105
1106        // A code from a different set is just as unknown.
1107        let others = RecoveryCodes::generate(1);
1108        assert!(!consume_recovery_code(&others.codes()[0], &mut hashes));
1109        assert_eq!(hashes.len(), 3);
1110    }
1111
1112    #[test]
1113    fn a_code_is_accepted_however_it_was_typed() {
1114        let codes = RecoveryCodes::generate(1);
1115        let code = codes.codes()[0].clone();
1116
1117        for typed in [
1118            code.clone(),
1119            code.to_lowercase(),
1120            code.replace('-', ""),
1121            code.replace('-', " "),
1122            format!("  {code}  "),
1123        ] {
1124            let mut hashes = codes.hashed_with(Cost::FAST);
1125            assert!(consume_recovery_code(&typed, &mut hashes), "{typed:?} should be accepted");
1126        }
1127    }
1128
1129    #[test]
1130    fn the_stored_hashes_reveal_nothing_about_the_codes() {
1131        let codes = RecoveryCodes::generate(5);
1132        let hashes = codes.hashed_with(Cost::FAST);
1133        let stored = hashes.join(" ");
1134
1135        for code in codes.codes() {
1136            assert!(!stored.contains(code.as_str()), "{code} was stored in the clear");
1137            assert!(!stored.contains(&code.replace('-', "")));
1138        }
1139
1140        for hash in &hashes {
1141            assert!(hash.starts_with("$argon2id$"), "unexpected hash: {hash}");
1142        }
1143
1144        // Two identical codes would still hash differently — each hash carries
1145        // its own salt — so the list leaks not even which codes repeat.
1146        let repeated = RecoveryCodes { codes: vec!["ABCDE-FGHJK".into(), "ABCDE-FGHJK".into()] };
1147        let pair = repeated.hashed_with(Cost::FAST);
1148        assert_ne!(pair[0], pair[1]);
1149    }
1150
1151    #[test]
1152    fn a_set_of_codes_never_prints_itself() {
1153        let codes = RecoveryCodes::generate(2);
1154        let printed = format!("{codes:?}");
1155
1156        for code in codes.codes() {
1157            assert!(!printed.contains(code.as_str()), "a code escaped into {printed}");
1158        }
1159        assert!(printed.contains("<redacted>"));
1160        assert!(printed.contains("count: 2"));
1161    }
1162
1163    #[test]
1164    fn a_single_code_can_be_hashed_and_spent_on_its_own() {
1165        let codes = RecoveryCodes::generate(1);
1166        let code = codes.codes()[0].clone();
1167
1168        let mut hashes = vec![hash_recovery_code(&code).expect("the default cost is valid")];
1169
1170        assert!(consume_recovery_code(&code.to_lowercase(), &mut hashes));
1171        assert!(hashes.is_empty());
1172    }
1173}