Skip to main content

acme_proxy/admin/
mfa.rs

1//! The second-factor operations, which both front ends dispatch to and neither
2//! owns.
3//!
4//! The same split [`crate::admin::users`] makes: no printing, no HTTP, no
5//! terminal. `src/cli/webadmin.rs` (`admin user totp …`) and
6//! `src/webadmin/handlers/mfa.rs` both come here, which is what keeps the replay
7//! guard, the session revocation and the "shown once" rule identical between
8//! them.
9//!
10//! The pure halves live next door -- [`crate::admin::totp`] for RFC 6238 and
11//! [`crate::admin::recovery`] for the codes. This file is where they meet a
12//! database.
13
14use std::sync::Arc;
15use uuid::Uuid;
16
17use tracing::{info, warn};
18
19use crate::admin::{password, recovery, totp};
20use crate::sqlite::admin_recovery_code::AdminRecoveryCode;
21use crate::sqlite::admin_session::AdminSession;
22use crate::sqlite::admin_user::AdminUser;
23use crate::sqlite::db::Database;
24use crate::sqlite::nonce::now_secs;
25
26/// Which of the two things an accepted submission was.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum MfaMethod {
29    Totp,
30    RecoveryCode,
31}
32
33impl MfaMethod {
34    /// The `method` field of `admin_mfa_verified`. A literal, so it stays
35    /// greppable.
36    #[must_use]
37    pub fn as_str(self) -> &'static str {
38        match self {
39            MfaMethod::Totp => "totp",
40            MfaMethod::RecoveryCode => "recovery_code",
41        }
42    }
43}
44
45/// The result of checking a second-factor submission.
46///
47/// Every variant but [`MfaOutcome::Accepted`] must reach the client as one
48/// `invalid_credentials`, exactly as [`crate::admin::users::AuthOutcome`]'s
49/// three failures do -- they are kept apart here so the *log* can say which.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum MfaOutcome {
52    Accepted {
53        via: MfaMethod,
54        /// Unspent recovery codes after this submission. The panel shows it;
55        /// a low number is the nudge to regenerate.
56        recovery_codes_left: i64,
57    },
58    /// Neither a valid code for this moment nor an unspent recovery code.
59    Rejected,
60    /// A *correct* TOTP code, resubmitted inside its own 30-second window
61    /// (RFC 6238 §5.2). Refused, and worth its own name: it is what somebody
62    /// replaying an observed code looks like.
63    Replayed,
64    /// No confirmed factor to verify against. Reachable only if a factor was
65    /// removed between the password step and this one.
66    NotEnrolled,
67}
68
69impl MfaOutcome {
70    /// The `reason` field of `admin_mfa_failed`, or `""` when it succeeded.
71    #[must_use]
72    pub fn reason(&self) -> &'static str {
73        match self {
74            MfaOutcome::Accepted { .. } => "",
75            MfaOutcome::Rejected => "wrong_code",
76            MfaOutcome::Replayed => "replayed",
77            MfaOutcome::NotEnrolled => "no_factor",
78        }
79    }
80}
81
82/// Checks a submission against this operator's factor, then against their
83/// recovery codes.
84///
85/// **Order matters.** A TOTP check is three HMACs; a recovery check is up to ten
86/// PBKDF2 runs and a write. The cheap and overwhelmingly common path goes
87/// first, and a submission that cannot be a recovery code by shape never starts
88/// the scan at all.
89///
90/// On success the TOTP path claims its time step
91/// ([`AdminUser::claim_totp_step`]) and the recovery path spends its code
92/// ([`AdminRecoveryCode::consume`]) -- in both cases the database, not this
93/// function, is what makes it single-use.
94pub async fn verify_second_factor(
95    user: &mut AdminUser,
96    submitted: &str,
97    database: Arc<Database>,
98) -> Result<MfaOutcome, sqlx::Error> {
99    let Some(secret) = user.totp_secret.clone() else {
100        return Ok(MfaOutcome::NotEnrolled);
101    };
102
103    let trimmed = submitted.trim();
104    if let Some(step) = totp::verify(&secret, trimmed, now_secs()) {
105        if !user.claim_totp_step(step, &database).await? {
106            return Ok(MfaOutcome::Replayed);
107        }
108        let left = AdminRecoveryCode::count_unused(user.id, &database).await?;
109        return Ok(MfaOutcome::Accepted {
110            via: MfaMethod::Totp,
111            recovery_codes_left: left,
112        });
113    }
114
115    let candidate = recovery::normalize(trimmed);
116    if !recovery::is_well_formed(&candidate) {
117        return Ok(MfaOutcome::Rejected);
118    }
119
120    for code in AdminRecoveryCode::list_unused(user.id, &database).await? {
121        match password::verify_password(&code.code_hash, &candidate) {
122            Ok(true) => {
123                if !AdminRecoveryCode::consume(code.id, &database).await? {
124                    // Lost the race to a concurrent submission of this very
125                    // code. Refusing is the correct answer for the loser.
126                    return Ok(MfaOutcome::Rejected);
127                }
128                let left = AdminRecoveryCode::count_unused(user.id, &database).await?;
129                warn!(event = "admin_mfa_recovery_code_used",
130                      outcome = "success",
131                      user_id = %user.id,
132                      username = %user.username,
133                      remaining = left);
134                return Ok(MfaOutcome::Accepted {
135                    via: MfaMethod::RecoveryCode,
136                    recovery_codes_left: left,
137                });
138            }
139            Ok(false) => {}
140            Err(error) => {
141                // A corrupt row is not a wrong code -- the same distinction
142                // `users::authenticate` draws. Skip it and say so; the operator
143                // has nine others and no way to guess this from a 401.
144                warn!(event = "admin_recovery_code_hash_unreadable",
145                      outcome = "failure",
146                      user_id = %user.id,
147                      code_id = %code.id,
148                      error = %error);
149            }
150        }
151    }
152
153    Ok(MfaOutcome::Rejected)
154}
155
156/// Mints a secret and stores it as *pending*, returning the two representations
157/// the enrolment page shows exactly once.
158///
159/// Nothing about the operator's current factor changes here: an enrolment that
160/// is started and abandoned leaves a live factor live, which is what makes
161/// "set up a new phone" safe to begin from an authenticated session.
162pub async fn begin_totp_enrolment(
163    user: &mut AdminUser,
164    base_url: &str,
165    database: Arc<Database>,
166) -> Result<totp::Enrolment, sqlx::Error> {
167    let account = totp::account_label(&user.username, base_url);
168    let enrolment = totp::begin_enrolment(totp::ISSUER, &account);
169    user.set_totp_pending(&enrolment.secret, &database).await?;
170    Ok(enrolment)
171}
172
173/// Re-renders an enrolment already begun, or begins one.
174///
175/// A page reload must not invalidate the secret the operator has just scanned
176/// into an app, so the pending bytes are shown again rather than replaced. The
177/// two other representations are derived, not stored -- the database keeps only
178/// the secret.
179pub async fn resume_or_begin_totp_enrolment(
180    user: &mut AdminUser,
181    base_url: &str,
182    database: Arc<Database>,
183) -> Result<totp::Enrolment, sqlx::Error> {
184    let Some(secret) = user.totp_pending_secret.clone() else {
185        return begin_totp_enrolment(user, base_url, database).await;
186    };
187
188    let account = totp::account_label(&user.username, base_url);
189    let secret_base32 = totp::base32_encode(&secret);
190    let uri = totp::provisioning_uri(&secret_base32, totp::ISSUER, &account);
191    Ok(totp::Enrolment {
192        secret,
193        secret_base32,
194        uri,
195    })
196}
197
198/// Confirms a pending enrolment against a code the authenticator produced.
199///
200/// On success: the pending secret becomes the real one, a fresh recovery set is
201/// issued and returned **once**, and every other session this operator holds is
202/// revoked. `None` is a wrong code, and leaves `totp_secret` exactly as it was.
203///
204/// `keep_session` is the token hash of the session doing the enrolling, so the
205/// operator is not logged out by their own action; `None` revokes every session,
206/// which is what a change made on their behalf should do.
207pub async fn confirm_totp_enrolment(
208    user: &mut AdminUser,
209    code: &str,
210    keep_session: Option<&str>,
211    database: Arc<Database>,
212) -> Result<Option<Vec<String>>, sqlx::Error> {
213    let Some(pending) = user.totp_pending_secret.clone() else {
214        return Ok(None);
215    };
216
217    let Some(step) = totp::verify(&pending, code.trim(), now_secs()) else {
218        return Ok(None);
219    };
220
221    user.confirm_totp(&database).await?;
222    // The code that proved the enrolment must not also work as the first login
223    // code. `confirm_totp` cleared the guard, so this is the first claim.
224    user.claim_totp_step(step, &database).await?;
225
226    let codes = issue_recovery_codes(user, database.clone()).await?;
227    revoke_other_sessions(user, keep_session, database).await?;
228
229    info!(event = "admin_mfa_enabled",
230          outcome = "success",
231          user_id = %user.id,
232          username = %user.username,
233          recovery_codes = codes.len());
234    Ok(Some(codes))
235}
236
237/// Removes the factor, any half-finished enrolment, the replay guard and every
238/// recovery code -- a code that recovers access to a factor that no longer
239/// exists is just a second password.
240///
241/// Revokes sessions on the same rule as [`confirm_totp_enrolment`]: a security
242/// control removed that left every other browser signed in would be a change in
243/// name only, which is `users::set_password`'s argument verbatim.
244pub async fn disable_totp(
245    user: &mut AdminUser,
246    keep_session: Option<&str>,
247    database: Arc<Database>,
248) -> Result<(), sqlx::Error> {
249    user.clear_totp(&database).await?;
250    AdminRecoveryCode::delete_for_user(user.id, &database).await?;
251    revoke_other_sessions(user, keep_session, database).await?;
252
253    info!(event = "admin_mfa_disabled", outcome = "success", user_id = %user.id, username = %user.username);
254    Ok(())
255}
256
257/// Mints a fresh recovery set, superseding the previous one, and returns it
258/// **once**. Nothing stores or logs the plaintext.
259pub async fn regenerate_recovery_codes(
260    user: &AdminUser,
261    database: Arc<Database>,
262) -> Result<Vec<String>, sqlx::Error> {
263    let codes = issue_recovery_codes(user, database).await?;
264    info!(event = "admin_mfa_recovery_codes_regenerated",
265          outcome = "success",
266          user_id = %user.id,
267          username = %user.username,
268          minted = codes.len());
269    Ok(codes)
270}
271
272/// How many unspent recovery codes this operator holds.
273pub async fn recovery_codes_remaining(
274    user_id: Uuid,
275    database: Arc<Database>,
276) -> Result<i64, sqlx::Error> {
277    AdminRecoveryCode::count_unused(user_id, &database).await
278}
279
280/// How many operators have no confirmed factor -- what the startup warning
281/// under `admin.require_mfa` counts.
282pub async fn operators_without_a_factor(database: Arc<Database>) -> Result<usize, sqlx::Error> {
283    Ok(AdminUser::list_all(&database)
284        .await?
285        .iter()
286        .filter(|user| !user.has_totp())
287        .count())
288}
289
290async fn issue_recovery_codes(
291    user: &AdminUser,
292    database: Arc<Database>,
293) -> Result<Vec<String>, sqlx::Error> {
294    let codes = recovery::generate_codes();
295    // Stored the way they are compared: normalised, so the grouping an operator
296    // reads off the screen never has to survive into the hash.
297    let hashes: Vec<String> = codes
298        .iter()
299        .map(|code| password::hash_generated_secret(&recovery::normalize(code)))
300        .collect();
301    AdminRecoveryCode::replace_all(user.id, &hashes, &database).await?;
302    Ok(codes)
303}
304
305async fn revoke_other_sessions(
306    user: &AdminUser,
307    keep_session: Option<&str>,
308    database: Arc<Database>,
309) -> Result<u64, sqlx::Error> {
310    match keep_session {
311        Some(token_hash) => {
312            AdminSession::delete_for_user_except(user.id, token_hash, &database).await
313        }
314        None => AdminSession::delete_for_user(user.id, &database).await,
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::admin::totp::{DIGITS, step_at, totp_at};
322    use crate::sqlite::admin_session::NewSession;
323
324    async fn db() -> Arc<Database> {
325        Arc::new(Database::connect_in_memory().await.unwrap())
326    }
327
328    async fn operator(database: Arc<Database>) -> AdminUser {
329        AdminUser::create("alice", "hash", &database).await.unwrap()
330    }
331
332    /// An operator with a confirmed factor, returning the secret so a test can
333    /// compute codes against it.
334    async fn enrolled(database: Arc<Database>) -> (AdminUser, Vec<u8>) {
335        let mut user = operator(database.clone()).await;
336        let enrolment = begin_totp_enrolment(&mut user, "http://localhost:3001", database.clone())
337            .await
338            .unwrap();
339        let code = totp_at(&enrolment.secret, step_at(now_secs()), DIGITS);
340        let codes = confirm_totp_enrolment(&mut user, &code, None, database)
341            .await
342            .unwrap()
343            .expect("a freshly generated code must confirm its own enrolment");
344        assert_eq!(codes.len(), recovery::CODE_COUNT);
345        (user, enrolment.secret)
346    }
347
348    #[tokio::test]
349    async fn an_operator_with_no_factor_is_not_enrolled() {
350        let db = db().await;
351        let mut user = operator(db.clone()).await;
352
353        assert_eq!(
354            verify_second_factor(&mut user, "123456", db).await.unwrap(),
355            MfaOutcome::NotEnrolled
356        );
357    }
358
359    #[tokio::test]
360    async fn enrolment_is_two_steps_and_a_wrong_code_finishes_neither() {
361        let db = db().await;
362        let mut user = operator(db.clone()).await;
363
364        let enrolment = begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
365            .await
366            .unwrap();
367        assert!(
368            !user.has_totp(),
369            "a pending enrolment is not a second factor"
370        );
371        assert!(user.has_pending_totp());
372
373        // A wrong code leaves the pending secret pending and mints no codes.
374        assert!(
375            confirm_totp_enrolment(&mut user, "000000", None, db.clone())
376                .await
377                .unwrap()
378                .is_none()
379        );
380        assert!(!user.has_totp());
381        assert!(user.has_pending_totp());
382        assert_eq!(
383            recovery_codes_remaining(user.id, db.clone()).await.unwrap(),
384            0
385        );
386
387        let code = totp_at(&enrolment.secret, step_at(now_secs()), DIGITS);
388        let codes = confirm_totp_enrolment(&mut user, &code, None, db.clone())
389            .await
390            .unwrap()
391            .unwrap();
392
393        assert!(user.has_totp());
394        assert!(
395            !user.has_pending_totp(),
396            "confirming must clear the pending column, not leave two secrets live"
397        );
398        assert_eq!(codes.len(), recovery::CODE_COUNT);
399        assert_eq!(
400            recovery_codes_remaining(user.id, db).await.unwrap(),
401            recovery::CODE_COUNT as i64
402        );
403    }
404
405    /// RFC 6238 §5.2. The code that proved the enrolment must not also be the
406    /// first login code, and no code may be spent twice inside its window.
407    #[tokio::test]
408    async fn a_correct_code_is_accepted_once_and_replayed_thereafter() {
409        let db = db().await;
410        let (mut user, secret) = enrolled(db.clone()).await;
411
412        // The enrolment already claimed a step, so the code for *that* step is
413        // a replay -- which is the point. Read it back rather than recomputing
414        // it from the clock: the two would disagree whenever the enrolment and
415        // this line straddle a 30-second boundary.
416        let claimed = user.totp_last_step.expect("enrolment claims its own step");
417        let code = totp_at(&secret, claimed, DIGITS);
418        assert_eq!(
419            verify_second_factor(&mut user, &code, db.clone())
420                .await
421                .unwrap(),
422            MfaOutcome::Replayed
423        );
424
425        // The next step's code is accepted, then it too is spent.
426        let next = totp_at(&secret, step_at(now_secs()) + 1, DIGITS);
427        assert_eq!(
428            verify_second_factor(&mut user, &next, db.clone())
429                .await
430                .unwrap(),
431            MfaOutcome::Accepted {
432                via: MfaMethod::Totp,
433                recovery_codes_left: recovery::CODE_COUNT as i64,
434            }
435        );
436        assert_eq!(
437            verify_second_factor(&mut user, &next, db).await.unwrap(),
438            MfaOutcome::Replayed
439        );
440    }
441
442    #[tokio::test]
443    async fn a_wrong_code_is_rejected_without_touching_the_replay_guard() {
444        let db = db().await;
445        let (mut user, secret) = enrolled(db.clone()).await;
446        let claimed = user.totp_last_step;
447
448        for wrong in ["000000", "12345", "abcdef", ""] {
449            assert_eq!(
450                verify_second_factor(&mut user, wrong, db.clone())
451                    .await
452                    .unwrap(),
453                MfaOutcome::Rejected,
454                "submission {wrong:?}"
455            );
456        }
457        assert_eq!(
458            user.totp_last_step, claimed,
459            "a wrong code must not advance the guard, or it would lock out the right one"
460        );
461
462        // And the guard really is where it was: the next step still works.
463        let next = totp_at(&secret, step_at(now_secs()) + 1, DIGITS);
464        assert!(matches!(
465            verify_second_factor(&mut user, &next, db).await.unwrap(),
466            MfaOutcome::Accepted { .. }
467        ));
468    }
469
470    #[tokio::test]
471    async fn a_recovery_code_is_accepted_once_and_decrements_the_count() {
472        let db = db().await;
473        let mut user = operator(db.clone()).await;
474        let enrolment = begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
475            .await
476            .unwrap();
477        let code = totp_at(&enrolment.secret, step_at(now_secs()), DIGITS);
478        let codes = confirm_totp_enrolment(&mut user, &code, None, db.clone())
479            .await
480            .unwrap()
481            .unwrap();
482
483        // Submitted exactly as the page rendered it, separator and all.
484        assert_eq!(
485            verify_second_factor(&mut user, &codes[0], db.clone())
486                .await
487                .unwrap(),
488            MfaOutcome::Accepted {
489                via: MfaMethod::RecoveryCode,
490                recovery_codes_left: recovery::CODE_COUNT as i64 - 1,
491            }
492        );
493        assert_eq!(
494            verify_second_factor(&mut user, &codes[0], db.clone())
495                .await
496                .unwrap(),
497            MfaOutcome::Rejected,
498            "single-use: a spent code is worth nothing"
499        );
500
501        // And retyped the ways a human retypes one.
502        assert_eq!(
503            verify_second_factor(&mut user, &codes[1].to_lowercase(), db.clone())
504                .await
505                .unwrap(),
506            MfaOutcome::Accepted {
507                via: MfaMethod::RecoveryCode,
508                recovery_codes_left: recovery::CODE_COUNT as i64 - 2,
509            }
510        );
511        assert_eq!(
512            verify_second_factor(&mut user, &codes[2].replace('-', " "), db.clone())
513                .await
514                .unwrap(),
515            MfaOutcome::Accepted {
516                via: MfaMethod::RecoveryCode,
517                recovery_codes_left: recovery::CODE_COUNT as i64 - 3,
518            }
519        );
520
521        assert_eq!(
522            recovery_codes_remaining(user.id, db).await.unwrap(),
523            recovery::CODE_COUNT as i64 - 3
524        );
525    }
526
527    #[tokio::test]
528    async fn regenerating_supersedes_the_previous_set() {
529        let db = db().await;
530        let (mut user, _) = enrolled(db.clone()).await;
531
532        let first = AdminRecoveryCode::list_unused(user.id, &db).await.unwrap();
533        let second = regenerate_recovery_codes(&user, db.clone()).await.unwrap();
534        assert_eq!(second.len(), recovery::CODE_COUNT);
535
536        // A code from the old set is worth nothing now. Verify through the
537        // stored hashes rather than the plaintext, which the first call never
538        // returned.
539        for code in AdminRecoveryCode::list_unused(user.id, &db).await.unwrap() {
540            assert!(first.iter().all(|old| old.id != code.id));
541        }
542        assert_eq!(
543            verify_second_factor(&mut user, &second[0], db.clone())
544                .await
545                .unwrap(),
546            MfaOutcome::Accepted {
547                via: MfaMethod::RecoveryCode,
548                recovery_codes_left: recovery::CODE_COUNT as i64 - 1,
549            }
550        );
551    }
552
553    #[tokio::test]
554    async fn disabling_clears_every_column_and_every_code() {
555        let db = db().await;
556        let (mut user, _) = enrolled(db.clone()).await;
557        assert!(user.has_totp());
558
559        disable_totp(&mut user, None, db.clone()).await.unwrap();
560
561        assert!(!user.has_totp());
562        assert!(!user.has_pending_totp());
563        assert_eq!(user.totp_last_step, None);
564        assert_eq!(
565            recovery_codes_remaining(user.id, db.clone()).await.unwrap(),
566            0,
567            "a recovery code for a factor that no longer exists is a second password"
568        );
569
570        // Re-read from the database: the in-memory sync must not be the only
571        // place this happened.
572        let reloaded = AdminUser::find_by_id(user.id, &db).await.unwrap().unwrap();
573        assert!(!reloaded.has_totp());
574        assert!(!reloaded.has_pending_totp());
575        assert_eq!(reloaded.totp_last_step, None);
576    }
577
578    #[tokio::test]
579    async fn a_factor_change_revokes_every_other_session() {
580        let db = db().await;
581        let mut user = operator(db.clone()).await;
582
583        let kept = AdminSession::create(
584            NewSession {
585                user_id: user.id,
586                token_hash: "kept-hash",
587                csrf_token: "csrf",
588                created_ip: None,
589                user_agent: None,
590            },
591            std::time::Duration::from_secs(3600),
592            &db,
593        )
594        .await
595        .unwrap();
596        AdminSession::create(
597            NewSession {
598                user_id: user.id,
599                token_hash: "other-hash",
600                csrf_token: "csrf",
601                created_ip: None,
602                user_agent: None,
603            },
604            std::time::Duration::from_secs(3600),
605            &db,
606        )
607        .await
608        .unwrap();
609
610        let enrolment = begin_totp_enrolment(&mut user, "http://localhost:3001", db.clone())
611            .await
612            .unwrap();
613        let code = totp_at(&enrolment.secret, step_at(now_secs()), DIGITS);
614        confirm_totp_enrolment(&mut user, &code, Some(&kept.token_hash), db.clone())
615            .await
616            .unwrap()
617            .unwrap();
618
619        let live = AdminSession::list_all(Some(user.id), &db).await.unwrap();
620        assert_eq!(live.len(), 1, "every other browser must be signed out");
621        assert_eq!(live[0].token_hash, "kept-hash");
622
623        // And disabling takes the last one too, when nothing is kept.
624        disable_totp(&mut user, None, db.clone()).await.unwrap();
625        assert!(
626            AdminSession::list_all(Some(user.id), &db)
627                .await
628                .unwrap()
629                .is_empty()
630        );
631    }
632
633    #[tokio::test]
634    async fn every_outcome_names_itself_for_the_log() {
635        assert_eq!(
636            MfaOutcome::Accepted {
637                via: MfaMethod::Totp,
638                recovery_codes_left: 10
639            }
640            .reason(),
641            ""
642        );
643        assert_eq!(MfaOutcome::Rejected.reason(), "wrong_code");
644        assert_eq!(MfaOutcome::Replayed.reason(), "replayed");
645        assert_eq!(MfaOutcome::NotEnrolled.reason(), "no_factor");
646        assert_eq!(MfaMethod::Totp.as_str(), "totp");
647        assert_eq!(MfaMethod::RecoveryCode.as_str(), "recovery_code");
648    }
649
650    #[tokio::test]
651    async fn the_startup_count_sees_only_confirmed_factors() {
652        let db = db().await;
653        let mut alice = operator(db.clone()).await;
654        AdminUser::create("bob", "hash", &db).await.unwrap();
655        assert_eq!(operators_without_a_factor(db.clone()).await.unwrap(), 2);
656
657        // A pending enrolment is not a factor, so it does not clear the count.
658        let enrolment = begin_totp_enrolment(&mut alice, "http://localhost:3001", db.clone())
659            .await
660            .unwrap();
661        assert_eq!(operators_without_a_factor(db.clone()).await.unwrap(), 2);
662
663        let code = totp_at(&enrolment.secret, step_at(now_secs()), DIGITS);
664        confirm_totp_enrolment(&mut alice, &code, None, db.clone())
665            .await
666            .unwrap()
667            .unwrap();
668        assert_eq!(operators_without_a_factor(db).await.unwrap(), 1);
669    }
670}