Skip to main content

acme_proxy_admin/webadmin/handlers/
mfa.rs

1//! `/api/mfa` — enrol, confirm, disable, and reissue recovery codes.
2//!
3//! The counterpart to [`super::session`], which owns the two *login* steps.
4//! Everything here acts on a session that already exists, in one of two states:
5//! `active` (an operator managing their own factor) or `pending_mfa` with no
6//! factor yet (`admin.require_mfa` forcing enrolment before the session becomes
7//! usable). [`crate::webadmin::session::EnrolWrite`] is what tells those two
8//! apart, and what refuses the third case -- a `pending_mfa` session that owes a
9//! *code*, which must never reach an enrolment route.
10
11use axum::Json;
12use axum::extract::State;
13use axum::http::{StatusCode, header};
14use axum::response::{IntoResponse, Response};
15use serde::Deserialize;
16use serde_json::json;
17use tracing::warn;
18
19use crate::admin::{mfa, totp};
20use crate::webadmin::AdminState;
21use crate::webadmin::error::AdminError;
22use crate::webadmin::handlers::session::finish_enrolment;
23use crate::webadmin::session::{AdminClientIp, Authenticated, EnrolWrite, SelfServiceWrite};
24
25/// The body of `POST /api/mfa/totp/confirm`: a code from the authenticator
26/// being enrolled.
27#[derive(Debug, Deserialize)]
28pub struct ConfirmRequest {
29    pub code: String,
30}
31
32/// The body of every route that *changes* a second factor rather than proving
33/// one. `password` is required only when a factor already exists — see
34/// [`check_step_up`].
35#[derive(Debug, Default, Deserialize)]
36pub struct StepUpRequest {
37    #[serde(default)]
38    pub password: String,
39}
40
41/// Re-authenticates an operator who is about to replace or remove an existing
42/// second factor.
43///
44/// A live session is not sufficient authority for this, and the reason is the
45/// blast radius rather than the change itself: `confirm_totp_enrolment` and
46/// `disable_totp` both call `revoke_other_sessions` and supersede the recovery
47/// codes. So somebody holding a stolen cookie can enrol *their* authenticator
48/// over the operator's, end every one of the operator's other sessions, and
49/// void the codes that would let them back in — converting a stolen session
50/// into a lockout that only `acme-proxy admin user totp reset` on the host can
51/// undo.
52///
53/// Only when a factor already exists. A first enrolment protects nothing, and
54/// demanding a password there would put one in the way of the `require_mfa`
55/// bootstrap, whose whole design is that enrolling must stay reachable.
56///
57/// The refusal is `invalid_credentials`, the same answer sign-in gives, so this
58/// is not a second oracle for whether a password is right.
59pub(crate) async fn check_step_up(
60    user: &acme_proxy_store::admin_user::AdminUser,
61    password: &str,
62    client: Option<std::net::IpAddr>,
63    logins: &crate::webadmin::session::LoginLimiter,
64) -> Result<(), AdminError> {
65    if !user.has_totp() {
66        return Ok(());
67    }
68    verify_current_password(user, password, client, logins).await
69}
70
71/// The part of step-up that always applies: proves the caller still knows the
72/// account password, rate-limited against the sign-in bucket.
73///
74/// [`check_step_up`] adds "only once there is something to protect" on top of
75/// this for the MFA routes, where a first enrolment protects nothing. A
76/// password change carries no such exemption -- ASVS V6.2.3 asks for the
77/// current password on *every* change of it, whether or not a second factor
78/// exists -- so `handlers::account::change_password` and its `/ui` twin call
79/// this directly instead of `check_step_up`.
80pub(crate) async fn verify_current_password(
81    user: &acme_proxy_store::admin_user::AdminUser,
82    password: &str,
83    client: Option<std::net::IpAddr>,
84    logins: &crate::webadmin::session::LoginLimiter,
85) -> Result<(), AdminError> {
86    // Checked **before** the KDF, which is `sign_in`'s reasoning verbatim: 600 000
87    // PBKDF2 iterations is a denial-of-service lever, and until this ran here an
88    // authenticated caller could pull it as fast as it could send requests.
89    // Guessing was unbounded too, which mattered more — this is the one check
90    // standing between a stolen cookie and the factor takeover the doc comment
91    // above describes.
92    //
93    // **The sign-in bucket, deliberately, not one of its own.** It is literally
94    // the same secret, and the panel already shares one bucket between the
95    // password step and the code step; a second budget here would hand an
96    // attacker `2 × login_max_attempts` guesses per window against one password.
97    // The cost is that a lockout earned on the account card also refuses sign-in
98    // from that address until `login_window_seconds` rolls over — already true
99    // of the code step, and the same remedy.
100    //
101    // What this does *not* bound: an `active` cookie is valid from any address
102    // on purpose (`created_ip` is forensics, never compared), so somebody
103    // rotating source addresses still gets `login_max_attempts` guesses each.
104    // Closing that needs a per-session counter, i.e. a column on
105    // `admin_sessions` — deliberately not done, because unlike a six-digit code
106    // a password behind 85 ms of PBKDF2 per guess is not reachable that way, and
107    // the address bucket already removes the DoS lever.
108    let attempt = match logins.begin(client) {
109        Ok(attempt) => attempt,
110        Err(retry_after) => {
111            warn!(
112                event = "admin_mfa_step_up_refused",
113                outcome = "failure",
114                username = %user.username,
115                reason = "rate_limited"
116            );
117            return Err(AdminError::rate_limited(retry_after));
118        }
119    };
120
121    match crate::admin::password::verify_password_off_runtime(&user.password_hash, password).await {
122        Ok(true) => {
123            // No `record_success`. `sign_in` moved its own to the *promotion*
124            // past the second factor for exactly this reason: clearing the
125            // bucket on a correct password would let whoever holds one reset it
126            // at will and brute-force the six digits behind it. A step-up caller
127            // is in that position by definition.
128            Ok(())
129        }
130        Ok(false) => {
131            attempt.failed();
132            warn!(event = "admin_mfa_step_up_refused", outcome = "failure", username = %user.username, reason = "wrong_password");
133            Err(AdminError::invalid_credentials())
134        }
135        Err(error) => {
136            // A stored hash this process cannot parse is a corrupt row, not a
137            // wrong password. Refuse rather than let the change through.
138            //
139            // No `attempt.failed()`: `decode` failed before the KDF ran, so
140            // nothing was guessed and no work was spent. Counting it would let
141            // one corrupt row lock its own owner out of sign-in as well — the
142            // one account that most needs to reach an operator.
143            //
144            // `warn`, matching `admin::users::authenticate`'s report of the
145            // same condition: one name emits at one level.
146            warn!(event = "admin_password_hash_unreadable",
147                  outcome = "failure",
148                  username = %user.username,
149                  error = %error);
150            Err(AdminError::invalid_credentials())
151        }
152    }
153}
154
155/// The four second-factor writes, each one function both front ends call.
156///
157/// What is shared is every decision: the step-up password, the refusals, the
158/// service call, and the notification and audit row that follow it. What stays
159/// in each handler is only its own rendering — a JSON body, or a card fragment
160/// with a banner. The two copies these replaced had already drifted: `/api`
161/// began a *new* enrolment where `/ui` resumed the pending one, so an operator
162/// who had scanned a secret got a different one back depending on which surface
163/// asked.
164mod actions {
165    use super::{AdminError, AdminState, check_step_up, mfa};
166    use crate::webadmin::CredentialChange;
167    use acme_proxy_core::audit::RequestContext;
168    use acme_proxy_store::admin_user::AdminUser;
169    use std::net::IpAddr;
170
171    /// What every one of these writes knows about the operator who asked: the
172    /// address it came from, which the notification names so a stolen session
173    /// shows up in it. The browser it names comes from the request's
174    /// `RequestContext`, which carries the same capped `User-Agent`.
175    #[derive(Clone, Copy)]
176    pub(super) struct Origin {
177        pub(super) client: Option<IpAddr>,
178    }
179
180    // These writes are self-service — the operator is the actor and the
181    // subject — so the only thing the audit row needs beyond the user is the
182    // request it arrived on. There is no `surface` field to set: both front
183    // ends write the same row.
184
185    /// Begins, or resumes, a TOTP enrolment.
186    ///
187    /// **Resumes** where one is pending: an operator who reloads after scanning
188    /// the secret into their authenticator must not be handed a different one,
189    /// and a second `POST` from a script is the same request twice.
190    pub(super) async fn begin_totp(
191        state: &AdminState,
192        user: &mut AdminUser,
193        password: &str,
194        origin: Origin,
195    ) -> Result<crate::admin::totp::Enrolment, AdminError> {
196        check_step_up(user, password, origin.client, &state.logins).await?;
197        Ok(mfa::resume_or_begin_totp_enrolment(
198            user,
199            &state.config.admin.base_url,
200            state.database.clone(),
201        )
202        .await?)
203    }
204
205    /// Proves a code against the pending enrolment. `None` is a code that did
206    /// not match, which each front end words for itself.
207    pub(super) async fn confirm_totp(
208        state: &AdminState,
209        request: &RequestContext,
210        user: &mut AdminUser,
211        code: &str,
212        keep: &str,
213        origin: Origin,
214    ) -> Result<Option<Vec<String>>, AdminError> {
215        let Some(codes) =
216            mfa::confirm_totp_enrolment(user, code, Some(keep), state.database.clone()).await?
217        else {
218            return Ok(None);
219        };
220        record(
221            state,
222            request,
223            user,
224            CredentialChange::SecondFactorEnabled,
225            origin,
226        )
227        .await;
228        Ok(Some(codes))
229    }
230
231    /// Removes the factor and every recovery code.
232    pub(super) async fn disable_totp(
233        state: &AdminState,
234        request: &RequestContext,
235        user: &mut AdminUser,
236        password: &str,
237        keep: &str,
238        origin: Origin,
239    ) -> Result<(), AdminError> {
240        if state.config.admin.require_mfa {
241            return Err(AdminError::conflict(
242                "mfa_required",
243                "admin.require_mfa is on: this server requires a second factor of every operator",
244            ));
245        }
246        check_step_up(user, password, origin.client, &state.logins).await?;
247        mfa::disable_totp(user, Some(keep), state.database.clone()).await?;
248        record(
249            state,
250            request,
251            user,
252            CredentialChange::SecondFactorDisabled,
253            origin,
254        )
255        .await;
256        Ok(())
257    }
258
259    /// Mints a fresh recovery set, superseding the last.
260    pub(super) async fn regenerate_recovery_codes(
261        state: &AdminState,
262        request: &RequestContext,
263        user: &AdminUser,
264        password: &str,
265        origin: Origin,
266    ) -> Result<Vec<String>, AdminError> {
267        if !user.has_totp() {
268            return Err(AdminError::conflict(
269                "mfa_not_enabled",
270                "there is no second factor for these codes to recover access to",
271            ));
272        }
273        check_step_up(user, password, origin.client, &state.logins).await?;
274        let codes = mfa::regenerate_recovery_codes(user, state.database.clone()).await?;
275        record(
276            state,
277            request,
278            user,
279            CredentialChange::RecoveryCodesRegenerated,
280            origin,
281        )
282        .await;
283        Ok(codes)
284    }
285
286    /// The notification and audit row every one of these owes, written after
287    /// the change and never before it.
288    async fn record(
289        state: &AdminState,
290        request: &RequestContext,
291        user: &AdminUser,
292        change: CredentialChange,
293        origin: Origin,
294    ) {
295        state
296            .record_credential_change(request, &user.username, user, change, true, origin.client)
297            .await;
298    }
299}
300
301/// The four shared writes as the pages side calls them, with `Origin` spelled
302/// out: a page handler holds the client address as a plain value and has no
303/// reason to know the shape this module packs it into.
304pub(crate) async fn begin_totp_for(
305    state: &AdminState,
306    user: &mut acme_proxy_store::admin_user::AdminUser,
307    password: &str,
308    client: Option<std::net::IpAddr>,
309) -> Result<crate::admin::totp::Enrolment, AdminError> {
310    actions::begin_totp(state, user, password, actions::Origin { client }).await
311}
312
313/// See [`begin_totp_for`].
314pub(crate) async fn confirm_totp_for(
315    state: &AdminState,
316    request: &acme_proxy_core::audit::RequestContext,
317    user: &mut acme_proxy_store::admin_user::AdminUser,
318    code: &str,
319    keep: &str,
320    client: Option<std::net::IpAddr>,
321) -> Result<Option<Vec<String>>, AdminError> {
322    actions::confirm_totp(state, request, user, code, keep, actions::Origin { client }).await
323}
324
325/// See [`begin_totp_for`].
326pub(crate) async fn disable_totp_for(
327    state: &AdminState,
328    request: &acme_proxy_core::audit::RequestContext,
329    user: &mut acme_proxy_store::admin_user::AdminUser,
330    password: &str,
331    keep: &str,
332    client: Option<std::net::IpAddr>,
333) -> Result<(), AdminError> {
334    actions::disable_totp(
335        state,
336        request,
337        user,
338        password,
339        keep,
340        actions::Origin { client },
341    )
342    .await
343}
344
345/// See [`begin_totp_for`].
346pub(crate) async fn regenerate_recovery_codes_for(
347    state: &AdminState,
348    request: &acme_proxy_core::audit::RequestContext,
349    user: &acme_proxy_store::admin_user::AdminUser,
350    password: &str,
351    client: Option<std::net::IpAddr>,
352) -> Result<Vec<String>, AdminError> {
353    actions::regenerate_recovery_codes(state, request, user, password, actions::Origin { client })
354        .await
355}
356
357/// `GET /api/mfa` — this operator's second-factor state.
358///
359/// Never the secret, and never a recovery code: only whether one exists and how
360/// many are left.
361pub async fn get_mfa(
362    State(state): State<AdminState>,
363    auth: Authenticated,
364) -> Result<Json<serde_json::Value>, AdminError> {
365    let remaining = mfa::recovery_codes_remaining(auth.user.id, state.database).await?;
366    Ok(Json(json!({
367        "totpEnabled": auth.user.has_totp(),
368        "enrolmentPending": auth.user.has_pending_totp(),
369        "recoveryCodesRemaining": remaining,
370    })))
371}
372
373/// `POST /api/mfa/totp` — begin an enrolment.
374///
375/// **The response is the only time the secret is readable.** It is stored as
376/// `totp_pending_secret` and never rendered again -- `GET /api/mfa` reports
377/// `enrolmentPending`, not the bytes. Starting a second enrolment overwrites the
378/// pending one and leaves any *confirmed* factor untouched, which is what makes
379/// "move to a new phone" safe to begin.
380///
381/// Requires the account password when a factor already exists: see
382/// [`check_step_up`]. `confirm` deliberately does not, since it can only
383/// confirm a secret this route already gated.
384pub async fn begin_totp(
385    State(state): State<AdminState>,
386    AdminClientIp(client): AdminClientIp,
387    enrol: EnrolWrite,
388    body: Option<Json<StepUpRequest>>,
389) -> Result<Response, AdminError> {
390    let mut user = enrol.user;
391    let enrolment = actions::begin_totp(
392        &state,
393        &mut user,
394        &body.unwrap_or_default().password,
395        actions::Origin { client },
396    )
397    .await?;
398
399    Ok((
400        StatusCode::CREATED,
401        Json(json!({
402            "secret": enrolment.secret_base32,
403            "uri": enrolment.uri,
404            "algorithm": "SHA1",
405            "digits": totp::DIGITS,
406            "period": totp::PERIOD_SECONDS,
407        })),
408    )
409        .into_response())
410}
411
412/// `POST /api/mfa/totp/confirm` — prove a code against the pending enrolment.
413///
414/// On success the pending secret becomes the real one and a fresh recovery set
415/// is returned **once**. When the session was still `pending_mfa` -- the
416/// `require_mfa` bootstrap -- this also completes the login, so the answer
417/// carries a rotated cookie: setting a factor up *is* the second step for an
418/// operator who had none.
419pub async fn confirm_totp(
420    State(state): State<AdminState>,
421    AdminClientIp(client): AdminClientIp,
422    enrol: EnrolWrite,
423    request_context: acme_proxy_core::audit::RequestContext,
424    Json(body): Json<ConfirmRequest>,
425) -> Result<Response, AdminError> {
426    let mut user = enrol.user;
427    let keep = enrol.session.token_hash.clone();
428
429    let Some(codes) = actions::confirm_totp(
430        &state,
431        &request_context,
432        &mut user,
433        &body.code,
434        &keep,
435        actions::Origin { client },
436    )
437    .await?
438    else {
439        return Err(AdminError::bad_request(
440            "that code does not match the pending enrolment",
441        ));
442    };
443
444    let body = json!({ "recoveryCodes": codes });
445
446    if !enrol.pending {
447        return Ok((StatusCode::OK, Json(body)).into_response());
448    }
449
450    // The `require_mfa` bootstrap: this confirmation completed the login, so it
451    // owes everything the code path owes. `finish_enrolment` is the one place
452    // that knows what, and the pages side calls the same function.
453    let (_, cookie) = finish_enrolment(
454        &state,
455        client,
456        &mut user,
457        &enrol.session.token_hash,
458        enrol.session.user_agent.clone(),
459    )
460    .await?;
461    Ok((StatusCode::OK, [(header::SET_COOKIE, cookie)], Json(body)).into_response())
462}
463
464/// `DELETE /api/mfa/totp` — remove the factor and every recovery code.
465///
466/// Refused while `admin.require_mfa` is on: the operator would be made to enrol
467/// again on their very next sign-in, so the only thing removing it achieves is
468/// a locked panel between the two.
469///
470/// Requires the account password ([`check_step_up`]) — removing a factor is the
471/// most consequential thing a stolen cookie could do here.
472pub async fn disable_totp(
473    State(state): State<AdminState>,
474    AdminClientIp(client): AdminClientIp,
475    SelfServiceWrite(auth): SelfServiceWrite,
476    request_context: acme_proxy_core::audit::RequestContext,
477    body: Option<Json<StepUpRequest>>,
478) -> Result<Response, AdminError> {
479    let mut user = auth.user;
480    actions::disable_totp(
481        &state,
482        &request_context,
483        &mut user,
484        &body.unwrap_or_default().password,
485        &auth.session.token_hash,
486        actions::Origin { client },
487    )
488    .await?;
489
490    Ok(StatusCode::NO_CONTENT.into_response())
491}
492
493/// `POST /api/mfa/recovery-codes` — mint a fresh set, **shown once**.
494///
495/// The previous set stops working the moment this answers, which is why it
496/// takes the account password ([`check_step_up`]): superseding the set a stolen
497/// session's owner would use to recover is the same lockout as replacing the
498/// factor.
499pub async fn regenerate_recovery_codes(
500    State(state): State<AdminState>,
501    AdminClientIp(client): AdminClientIp,
502    SelfServiceWrite(auth): SelfServiceWrite,
503    request_context: acme_proxy_core::audit::RequestContext,
504    body: Option<Json<StepUpRequest>>,
505) -> Result<Json<serde_json::Value>, AdminError> {
506    let codes = actions::regenerate_recovery_codes(
507        &state,
508        &request_context,
509        &auth.user,
510        &body.unwrap_or_default().password,
511        actions::Origin { client },
512    )
513    .await?;
514
515    Ok(Json(json!({ "recoveryCodes": codes })))
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521    use acme_proxy_store::admin_user::AdminUser;
522
523    fn user_with(password_hash: &str, totp: Option<&[u8]>) -> AdminUser {
524        AdminUser {
525            id: acme_proxy_store::testutil::ADMIN_FIXTURE_ID,
526            username: "alice".to_string(),
527            password_hash: password_hash.to_string(),
528            status: "active".to_string(),
529            role: None,
530            totp_secret: totp.map(<[u8]>::to_vec),
531            totp_pending_secret: None,
532            totp_last_step: None,
533            created_at: 1_700_000_000,
534            updated_at: 1_700_000_000,
535            last_login_at: None,
536            contact_email: None,
537            known_login_ips: Vec::new(),
538        }
539    }
540
541    use crate::webadmin::session::LoginLimiter;
542    use std::net::IpAddr;
543
544    const MAX_ATTEMPTS: u32 = 5;
545
546    fn limiter() -> LoginLimiter {
547        LoginLimiter::new(MAX_ATTEMPTS, 300)
548    }
549
550    fn client() -> Option<IpAddr> {
551        Some("198.51.100.7".parse().expect("a literal address"))
552    }
553
554    /// The encoded form is self-describing, so a cheaper cost still exercises
555    /// every branch here at a fraction of the wall clock — which matters,
556    /// because these tests deliberately run the KDF several times over.
557    fn cheap_hash(password: &str) -> String {
558        crate::admin::password::hash_generated_secret(password)
559    }
560
561    /// The gate is scoped to operators who *have* something to protect.
562    #[tokio::test]
563    async fn a_factorless_operator_passes_without_a_password() {
564        let user = user_with("not-even-a-valid-hash", None);
565        let logins = limiter();
566        assert!(check_step_up(&user, "", client(), &logins).await.is_ok());
567        assert!(
568            check_step_up(&user, "anything", client(), &logins)
569                .await
570                .is_ok()
571        );
572    }
573
574    /// The inverse of the test above: `verify_current_password` carries no
575    /// `has_totp()` exemption, since a password change needs the current
576    /// password proven whether or not a second factor exists.
577    #[tokio::test]
578    async fn verify_current_password_runs_even_for_a_factorless_operator() {
579        let hash = cheap_hash("correct horse battery staple");
580        let user = user_with(&hash, None);
581        let logins = limiter();
582
583        assert!(
584            verify_current_password(&user, "correct horse battery staple", client(), &logins)
585                .await
586                .is_ok()
587        );
588        let error = verify_current_password(&user, "wrong", client(), &logins)
589            .await
590            .expect_err("a wrong password must refuse even with no factor enrolled");
591        assert_eq!(error.code, AdminError::invalid_credentials().code);
592    }
593
594    #[tokio::test]
595    async fn a_live_factor_needs_the_right_password() {
596        let hash = cheap_hash("correct horse battery staple");
597        let user = user_with(&hash, Some(b"secret"));
598        let logins = limiter();
599
600        assert!(
601            check_step_up(&user, "correct horse battery staple", client(), &logins)
602                .await
603                .is_ok()
604        );
605        for wrong in ["", "Correct horse battery staple", "wrong"] {
606            let Err(error) = check_step_up(&user, wrong, client(), &logins).await else {
607                panic!("{wrong:?} must be refused");
608            };
609            // Byte-identical to a wrong password at sign-in, so this is not a
610            // second oracle for whether one is right.
611            assert_eq!(error.code, AdminError::invalid_credentials().code);
612            assert_eq!(error.status, AdminError::invalid_credentials().status);
613        }
614    }
615
616    /// A stored hash this process cannot parse is a corrupt row, not a correct
617    /// password. It must refuse rather than let the factor change through.
618    #[tokio::test]
619    async fn an_unreadable_stored_hash_refuses_rather_than_admits() {
620        let user = user_with("pbkdf2-sha256$not-a-number$salt$digest", Some(b"secret"));
621        let logins = limiter();
622        let error = check_step_up(&user, "anything", client(), &logins)
623            .await
624            .expect_err("a corrupt hash must refuse");
625        assert_eq!(error.code, AdminError::invalid_credentials().code);
626    }
627
628    /// Guessing the password here is bounded by the same budget sign-in uses.
629    ///
630    /// The assertion that matters is the *last* one: once the address is locked
631    /// out the **correct** password is refused too, which is the only thing that
632    /// can prove the limiter runs before the KDF rather than after it. Checking
633    /// it afterwards would bound nothing — the expensive work would already be
634    /// done, and that expense is the denial-of-service lever `sign_in` runs its
635    /// own check ahead of.
636    #[tokio::test]
637    async fn a_wrong_password_counts_against_the_login_limiter() {
638        let hash = cheap_hash("correct horse battery staple");
639        let user = user_with(&hash, Some(b"secret"));
640        let logins = limiter();
641
642        for _ in 0..MAX_ATTEMPTS {
643            let error = check_step_up(&user, "wrong", client(), &logins)
644                .await
645                .expect_err("a wrong password must refuse");
646            assert_eq!(error.code, AdminError::invalid_credentials().code);
647        }
648
649        let error = check_step_up(&user, "correct horse battery staple", client(), &logins)
650            .await
651            .expect_err("the budget is spent, so even a correct password waits");
652        assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS);
653    }
654
655    /// A correct password does **not** clear the bucket.
656    ///
657    /// `sign_in` moved its own `record_success` past the second factor for
658    /// exactly this reason: somebody holding a correct password must not be able
659    /// to reset the budget at will and brute-force the six digits behind it. A
660    /// step-up caller holds a session, so they are in that position by
661    /// definition.
662    #[tokio::test]
663    async fn a_correct_password_does_not_clear_the_bucket() {
664        let hash = cheap_hash("correct horse battery staple");
665        let user = user_with(&hash, Some(b"secret"));
666        let logins = limiter();
667
668        for _ in 0..MAX_ATTEMPTS - 1 {
669            assert!(
670                check_step_up(&user, "wrong", client(), &logins)
671                    .await
672                    .is_err()
673            );
674        }
675        assert!(
676            check_step_up(&user, "correct horse battery staple", client(), &logins)
677                .await
678                .is_ok()
679        );
680
681        // One guess left, not a fresh five.
682        assert!(
683            check_step_up(&user, "wrong", client(), &logins)
684                .await
685                .is_err()
686        );
687        let error = check_step_up(&user, "wrong", client(), &logins)
688            .await
689            .expect_err("the budget survives a correct password");
690        assert_eq!(error.status, StatusCode::TOO_MANY_REQUESTS);
691    }
692
693    /// An operator with no factor never spends the budget, since the gate
694    /// returns before any KDF runs — there is nothing to bound, and charging
695    /// them would let a factorless account lock its own address out of sign-in.
696    #[tokio::test]
697    async fn a_factorless_operator_never_touches_the_limiter() {
698        let user = user_with("not-even-a-valid-hash", None);
699        let logins = limiter();
700
701        for _ in 0..MAX_ATTEMPTS + 1 {
702            assert!(
703                check_step_up(&user, "anything", client(), &logins)
704                    .await
705                    .is_ok()
706            );
707        }
708        assert!(logins.begin(client()).is_ok());
709    }
710
711    /// A corrupt row refuses, but must not lock its own owner out of sign-in:
712    /// `decode` failed before the KDF ran, so nothing was guessed and no work
713    /// was spent.
714    #[tokio::test]
715    async fn an_unreadable_stored_hash_does_not_lock_the_address_out() {
716        let user = user_with("pbkdf2-sha256$not-a-number$salt$digest", Some(b"secret"));
717        let logins = limiter();
718
719        for _ in 0..MAX_ATTEMPTS + 1 {
720            assert!(
721                check_step_up(&user, "anything", client(), &logins)
722                    .await
723                    .is_err()
724            );
725        }
726        assert!(logins.begin(client()).is_ok());
727    }
728}