Skip to main content

rustio_admin/auth/
mfa.rs

1//! TOTP multi-factor authentication (R3).
2//!
3//! See `DESIGN_R3_MFA.md` for the canonical contract this module
4//! implements. R3 ships in 0.7.0; this module owns the MFA
5//! runtime — TOTP enrolment + verification, backup-code
6//! generation + consumption + regeneration, MFA disable, and the
7//! AES-256-GCM secret-encryption helpers. The HTTP wrappers will
8//! live in `admin::mfa_handlers`; routes are registered in
9//! `admin::routes::register_admin_routes` after R2's
10//! admin-recovery routes. The testcontainers integration suite
11//! under `tests/integration_*.rs` exercises the DB-touching paths
12//! end-to-end against an ephemeral Postgres, gated behind
13//! `--features integration-test` per `DESIGN_R3_MFA.md` §13.3.
14//!
15//! ## Visibility note
16//!
17//! Items here are `pub` (rather than `pub(crate)`) so the
18//! `crate::__integration` re-export module can re-export them
19//! under the `integration-test` feature. The MODULE itself is
20//! `pub(crate)` (`auth::mod`), so the canonical path
21//! `rustio_admin::auth::mfa::*` remains closed to external
22//! callers — `__integration` is the only door, and it is
23//! itself feature-gated + `#[doc(hidden)]`. Same pattern as
24//! `auth::recovery_admin`.
25//!
26//! ## What lives here today
27//!
28//! - [`migrate_user_mfa_schema`] — adds the additive R3 columns
29//!   on `rustio_users` (`mfa_enabled`, `mfa_secret_ciphertext`,
30//!   `mfa_secret_key_id`, `mfa_last_used_step`) plus the new
31//!   `rustio_mfa_backup_codes` table and a per-user partial
32//!   index on `(user_id) WHERE used_at IS NULL` for the
33//!   verification-path scan (§7 of the design doc). R3 commit #1.
34//! - [`MfaPolicy`] — the four-variant enum that controls
35//!   framework-wide MFA enforcement (§11.1 of the design doc).
36//!   `Disabled` / `Optional` (default) / `Required` /
37//!   `RequiredForRoles(&[Role])`. The variant is data-only at
38//!   this commit; the `login_guard` routing that consults it
39//!   lands in a later commit (§12.3). Wired onto `Admin` via
40//!   [`crate::admin::types::Admin::require_mfa`]. R3 commit #2.
41//! - [`MfaKey`] / [`wrap_secret`] / [`unwrap_secret`] —
42//!   AES-256-GCM secret encryption helpers (§8.1 of the design
43//!   doc, D1). The plaintext TOTP secret is encrypted before it
44//!   reaches the database; storage layout is `nonce ||
45//!   ciphertext || tag`. `MfaKey::from_env` reads
46//!   `RUSTIO_SECRET_KEY` (32-byte URL-safe-base64); the boot
47//!   refusal when `MfaPolicy != Disabled` and the env var is
48//!   unset lands in a later commit. Round-trip + tamper +
49//!   wrong-key detection are pinned by unit tests. R3 commit #3.
50//! - [`generate_backup_codes`] / [`hash_backup_code`] /
51//!   [`verify_backup_code`] / [`normalise_backup_code`] +
52//!   [`BACKUP_CODE_COUNT`] / [`BACKUP_CODE_LEN`] — the
53//!   backup-code surface (§8.1, D2 + D7). 8 codes per batch in
54//!   the locked `XXXX-XXXX` format from the 31-char
55//!   ambiguity-stripped alphabet (no `0/O/1/I/L`); Argon2id with
56//!   low-memory params (`m = 16 MiB`, `t = 2`, `p = 1`); the
57//!   normalise function uppercases and strips the hyphen so the
58//!   user can copy with or without the separator. Every helper
59//!   is marked `#[allow(dead_code)]` until the enrolment +
60//!   verification runtime wires the call sites in R3 commits
61//!   #6 and #7. R3 commit #4.
62//! - [`current_step`] / [`generate_totp`] / [`verify_totp`] —
63//!   hand-rolled RFC 6238 TOTP (§9.4). HMAC-SHA1 (the
64//!   authenticator-app-default algorithm; `algorithm=SHA256`
65//!   variants exist but interop is best with SHA-1), 30-second
66//!   step interval, ±1 step skew tolerance (per Appendix B
67//!   locked decisions). 6-digit codes per industry standard.
68//!   `verify_totp` returns the step that matched on success so
69//!   the caller can stamp `mfa_last_used_step` for replay
70//!   protection (D4). Pinned by the canonical RFC 6238
71//!   Appendix B test vectors (truncated from 8-digit to 6-digit
72//!   per authenticator-app standard). R3 commit #5.
73//! - [`provision_secret`] / [`ProvisionedSecret`] /
74//!   [`confirm_enrolment`] / [`EnrolOutcome`] — the enrolment
75//!   runtime (§4.1, §9). `provision_secret` is pure: 20 random
76//!   bytes for RFC 6238 + base32 encoding for the QR / manual
77//!   entry display. `confirm_enrolment` is the DB-touching
78//!   path: verifies the user's first TOTP code, AES-GCM
79//!   encrypts the secret, stores the row, generates +
80//!   Argon2id-hashes 8 backup codes, INSERTs them, and emits
81//!   `AuditEvent::MfaEnabled`. The first MFA function that
82//!   touches `rustio_users.mfa_enabled` and writes
83//!   `rustio_mfa_backup_codes`. The HTTP handler that calls it
84//!   lands in a later commit. R3 commit #6.
85//! - [`verify_totp_for_user`] / [`VerifyOutcome`] — the TOTP
86//!   verification runtime (§4.2, D4). Reads the encrypted
87//!   secret + `mfa_last_used_step` from the user row,
88//!   decrypts via [`unwrap_secret`], runs [`verify_totp`]
89//!   against the candidate, and rejects steps at or below
90//!   the stored value (D4 replay protection). On success
91//!   stamps the new step. No audit row — TOTP success is
92//!   captured via the session-promotion `parent_session_id`
93//!   lineage per §8.3, not a separate event. R3 commit #7.
94//! - [`consume_backup_code`] / [`BackupConsumeOutcome`] — the
95//!   backup-code consume runtime (§4.4, D7). Argon2id-verifies
96//!   the candidate against every unused row for the user
97//!   (constant-time iteration), atomically marks the matching
98//!   row `used_at = NOW()`, emits
99//!   `AuditEvent::MfaCodeConsumed` with metadata
100//!   `{ code_id, remaining_codes, via }`. Single-use enforced
101//!   at the index level + a conditional UPDATE that races
102//!   safely. R3 commit #8.
103//! - [`disable_mfa`] / [`DisableOutcome`] — the self-disable
104//!   runtime (§4.3). Clears all four MFA columns on the user
105//!   row, deletes every backup-code row, calls
106//!   `auth::sessions::invalidate_sessions` with
107//!   `SessionInvalidationReason::MfaDisabled` (Doctrine 22's
108//!   sole writer of `revoked_at`), and emits
109//!   `AuditEvent::MfaDisabled`. The first R3 runtime that
110//!   goes through `invalidate_sessions` — the substrate
111//!   carries through unchanged. R3 commit #9.
112//! - [`regenerate_backup_codes`] / [`RegenOutcome`] — the
113//!   regenerate runtime (§4.5, D3). Atomic transaction:
114//!   `SELECT … FOR UPDATE` on the user row to serialise
115//!   concurrent regenerates, DELETE every existing
116//!   backup-code row, INSERT 8 fresh hashed rows, then commit.
117//!   Emits the new `AuditEvent::BackupCodesRegenerated`
118//!   variant with metadata
119//!   `{ previous_codes_invalidated, new_codes_count }`.
120//!   D3 enforced at the SQL level — the old batch is
121//!   unrecoverable from the moment the transaction commits.
122//!   R3 commit #10.
123//! - [`promote_session_to_mfa_verified`] — the trust-escalation
124//!   primitive (`DESIGN_SESSIONS.md` §11, Doctrine 17). Mints
125//!   a fresh `mfa_verified` session row with
126//!   `parent_session_id` pointing at the caller's current
127//!   row, then revokes the parent via
128//!   `auth::sessions::invalidate_sessions` with
129//!   `SessionInvalidationReason::TrustEscalation`. Returns the
130//!   new plaintext token for the caller (handler) to set as
131//!   the cookie. Used by the verify POST handler (later
132//!   commit) after `verify_totp_for_user` or
133//!   `consume_backup_code` returns success. R3 commit #11.
134//!
135//! Subsequent commits will add the HTTP handlers, route
136//! registration, and `MfaPolicy` routing into `login_guard`
137//! (§10, §12.3).
138//!
139//! ## Doctrine 22 reminder
140//!
141//! Centralised invalidation remains the single writer of
142//! `revoked_at` on `rustio_sessions`. R3 will pass `MfaEnabled`
143//! and `MfaDisabled` reasons to
144//! [`crate::auth::sessions::invalidate_sessions`] when the
145//! enrolment / disable runtime lands; nothing in this module
146//! writes to `revoked_at` directly. See `DESIGN_SESSIONS.md`
147//! Doctrine 22 for the grep proof contract.
148//!
149//! ## At-rest secrecy reminder
150//!
151//! TOTP secrets are encrypted with AES-256-GCM keyed by
152//! `RUSTIO_SECRET_KEY` before persisting (D1 of the R3 design
153//! doc). Backup codes are Argon2id-hashed with low-memory params
154//! (D2). Plaintext TOTP secrets and plaintext backup codes
155//! exist only in process memory during enrolment + verification.
156//! The schema column `mfa_secret_ciphertext BYTEA` carries the
157//! AEAD output (`nonce || ciphertext || tag`); the
158//! `code_hash TEXT` column carries the Argon2id hash. The
159//! schema enforced here is the persistence contract for those
160//! invariants.
161//!
162//! Idempotent. Safe to call on every boot. `auth::init_tables`
163//! invokes [`migrate_user_mfa_schema`] after R2's
164//! `recovery_admin::migrate_user_lockout_schema`.
165
166use aes_gcm::aead::{Aead, KeyInit};
167use aes_gcm::{Aes256Gcm, Key as GcmKey, Nonce};
168use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
169use argon2::{Algorithm, Argon2, Params, Version};
170use base64::engine::general_purpose::URL_SAFE_NO_PAD;
171use base64::Engine;
172use chrono::{Duration as ChronoDuration, Utc};
173use hmac::{Hmac, Mac};
174use rand::{Rng, RngCore};
175use sha1::Sha1;
176
177use crate::admin::audit::{record as audit_record, ActionType, AuditEvent, LogEntry};
178use crate::admin::builtin::client_ip;
179use crate::auth::sessions::{
180    hash_token_for_storage, invalidate_sessions, random_token, SessionInvalidationReason,
181    SessionTarget,
182};
183use crate::auth::Role;
184use crate::error::{Error, Result};
185use crate::http::Request;
186use crate::orm::Db;
187
188/// Session lifetime for trust-escalated `mfa_verified` rows,
189/// matching the framework's `SESSION_LENGTH_DAYS` constant in
190/// `auth::sessions`. Kept local here so this module does not
191/// reach across to `pub(crate)` consts; if the canonical
192/// constant ever moves out of `pub(crate)` scope it should be
193/// imported in place of this local copy.
194const MFA_VERIFIED_SESSION_DAYS: i64 = 14;
195
196type HmacSha1 = Hmac<Sha1>;
197
198// internal:
199/// AES-256-GCM key material for TOTP secret encryption (D1).
200///
201/// 32 raw bytes — the AES-256 key. Constructed from the
202/// `RUSTIO_SECRET_KEY` environment variable (32-byte
203/// URL-safe-base64-encoded) via [`MfaKey::from_env`], or from
204/// raw bytes via [`MfaKey::from_bytes`] (for tests / explicit
205/// construction).
206///
207/// `Clone` is intentional — the key is held by the framework's
208/// `MfaSecretKeyResolver` (future commit) and cloned cheaply
209/// onto cipher instances per-encryption. `Copy` is intentionally
210/// NOT derived: a `Copy` key would silently scatter copies into
211/// every stack frame that touches it; an explicit `.clone()`
212/// makes key usage auditable on review.
213///
214/// Plaintext key material lives only in process memory. The
215/// `Drop` is a no-op intentionally — the operating system zeroes
216/// freed pages on most production deployments, and the framework
217/// does not promise constant-time secure-erase on Drop. Operators
218/// who require zeroize-on-drop semantics can wrap this type in
219/// the `zeroize` crate's `Zeroizing` shim at the construction
220/// site.
221#[derive(Clone)]
222#[allow(dead_code)] // call sites land in R3 commit #6+ (enrol / verify runtime)
223pub struct MfaKey([u8; 32]);
224
225#[allow(dead_code)] // see MfaKey type comment — light up in R3 commit #6+
226impl MfaKey {
227    // internal:
228    /// Read the framework-wide secret key from the
229    /// `RUSTIO_SECRET_KEY` environment variable.
230    ///
231    /// The variable carries 32 raw key bytes, encoded as
232    /// URL-safe-base64 without padding. After decoding the
233    /// constructor verifies the byte length is exactly 32.
234    ///
235    /// **Failure modes** (all surface as `Error::Internal` —
236    /// the failure happens at boot, not at request time):
237    ///
238    /// - Env var unset.
239    /// - Decode failure (invalid URL-safe-base64 alphabet,
240    ///   stray padding, etc.).
241    /// - Wrong length after decode (≠ 32 bytes).
242    ///
243    /// The boot guard that ties this requirement to
244    /// `MfaPolicy != Disabled` is wired in a later commit; this
245    /// constructor reports the failure but does NOT enforce
246    /// "policy says Disabled, so missing key is fine."
247    pub(crate) fn from_env() -> Result<Self> {
248        let raw = std::env::var("RUSTIO_SECRET_KEY").map_err(|_| {
249            Error::Internal(
250                "RUSTIO_SECRET_KEY env var is unset; required when MfaPolicy != Disabled".into(),
251            )
252        })?;
253        let decoded = URL_SAFE_NO_PAD.decode(raw.trim()).map_err(|e| {
254            Error::Internal(format!(
255                "RUSTIO_SECRET_KEY is not valid URL-safe-base64 (no padding): {e}"
256            ))
257        })?;
258        let bytes: [u8; 32] = decoded.as_slice().try_into().map_err(|_| {
259            Error::Internal(format!(
260                "RUSTIO_SECRET_KEY decodes to {} bytes; AES-256 requires exactly 32",
261                decoded.len()
262            ))
263        })?;
264        Ok(Self(bytes))
265    }
266
267    // internal:
268    /// Construct from raw 32 bytes. Used by tests and explicit
269    /// project wiring (e.g. a project that derives the key from
270    /// AWS KMS / HashiCorp Vault rather than an env var).
271    pub fn from_bytes(bytes: [u8; 32]) -> Self {
272        Self(bytes)
273    }
274
275    /// Borrow the 32-byte key for the AES-256-GCM cipher's
276    /// `KeyInit`. The reference is bounded to the borrow's
277    /// lifetime; callers cannot retain it past the cipher
278    /// construction.
279    fn as_bytes(&self) -> &[u8; 32] {
280        &self.0
281    }
282}
283
284// internal:
285/// Encrypt `plaintext` under `key` with AES-256-GCM.
286///
287/// Returns the on-disk byte layout: `nonce (12 bytes) ||
288/// ciphertext || auth_tag (16 bytes)`. The nonce is generated
289/// fresh per call from `rand::thread_rng()`.
290///
291/// **Output length** is `12 + plaintext.len() + 16`, exactly the
292/// shape persisted in `rustio_users.mfa_secret_ciphertext` per
293/// `DESIGN_R3_MFA.md` §8.1. Callers do not need to track the
294/// nonce separately — it travels with the ciphertext.
295///
296/// **Infallible.** AEAD encryption with `aes-gcm` cannot fail
297/// for in-memory plaintexts; the method that returns `Result` on
298/// the underlying API exists for streaming-mode callers we do
299/// not use. Returning `Vec<u8>` directly keeps the call sites
300/// simple.
301#[allow(dead_code)] // call site lands in R3 commit #6 (enrol_secret runtime)
302pub(crate) fn wrap_secret(plaintext: &[u8], key: &MfaKey) -> Vec<u8> {
303    let mut nonce_bytes = [0u8; 12];
304    rand::thread_rng().fill_bytes(&mut nonce_bytes);
305    let nonce = Nonce::from_slice(&nonce_bytes);
306
307    let cipher = Aes256Gcm::new(GcmKey::<Aes256Gcm>::from_slice(key.as_bytes()));
308    let ciphertext = cipher
309        .encrypt(nonce, plaintext)
310        .expect("AES-256-GCM encrypt cannot fail for in-memory plaintext");
311
312    let mut out = Vec::with_capacity(12 + ciphertext.len());
313    out.extend_from_slice(&nonce_bytes);
314    out.extend_from_slice(&ciphertext);
315    out
316}
317
318// internal:
319/// Decrypt `input` (`nonce || ciphertext || tag`) under `key`.
320///
321/// **Failure modes** (all surface as `Error::Internal` — the
322/// recovery is operator-side; the user surface treats this as
323/// "session invalid" via the verify handler's outcome mapping):
324///
325/// - Input shorter than 28 bytes (no room for nonce + tag).
326/// - AEAD verification failure: tampered ciphertext, wrong key,
327///   nonce reuse on a different message, etc. The library does
328///   not distinguish between these — they all reduce to "the
329///   tag did not verify."
330///
331/// The function is constant-time at the AEAD primitive level;
332/// the framework adds no timing-leak surface on top of it.
333#[allow(dead_code)] // call site lands in R3 commit #7 (verify_totp runtime)
334pub(crate) fn unwrap_secret(input: &[u8], key: &MfaKey) -> Result<Vec<u8>> {
335    if input.len() < 12 + 16 {
336        return Err(Error::Internal(format!(
337            "MFA ciphertext too short ({} bytes); minimum is 28 (nonce + tag)",
338            input.len()
339        )));
340    }
341    let (nonce_bytes, ciphertext) = input.split_at(12);
342    let nonce = Nonce::from_slice(nonce_bytes);
343
344    let cipher = Aes256Gcm::new(GcmKey::<Aes256Gcm>::from_slice(key.as_bytes()));
345    cipher
346        .decrypt(nonce, ciphertext)
347        .map_err(|_| Error::Internal("MFA ciphertext failed AEAD verification".into()))
348}
349
350// -----------------------------------------------------------------
351// Backup codes (R3 commit #4)
352// -----------------------------------------------------------------
353
354// internal:
355/// Number of backup codes generated per batch. Locked at 8 per
356/// `DESIGN_R3_MFA.md` Appendix B. Industry-standard range is
357/// 8-16; 8 is enough for emergency use without bloating the
358/// post-enrolment confirmation page.
359#[allow(dead_code)] // call site lands in R3 commit #6 (enrolment runtime)
360pub const BACKUP_CODE_COUNT: usize = 8;
361
362// internal:
363/// Backup-code length in characters, excluding the visual
364/// hyphen separator at position 4. Locked at 8 (rendered as
365/// `XXXX-XXXX`) per `DESIGN_R3_MFA.md` Appendix B.
366pub(crate) const BACKUP_CODE_LEN: usize = 8;
367
368/// 31-character ambiguity-stripped alphabet for backup codes.
369/// Excludes `0` / `O` (digit zero / letter O), `1` / `I` /
370/// `L` (digit one / letter I / letter L). Per
371/// `DESIGN_R3_MFA.md` Appendix B locked decision; the alphabet
372/// is the persistence contract — changing it breaks any code
373/// that was issued under a different alphabet.
374///
375/// Entropy per backup code: `8 chars × log2(31) ≈ 39.6 bits`
376/// — adequate for single-use rate-limited verification. The
377/// design doc rounds to "≈41 bits" approximately.
378const BACKUP_CODE_ALPHABET: &[u8] = b"23456789ABCDEFGHJKMNPQRSTUVWXYZ";
379
380/// Argon2id parameters for backup-code hashing. Locked at
381/// `m = 16 MiB / t = 2 / p = 1` per `DESIGN_R3_MFA.md` §8.1.
382///
383/// Lower than full password Argon2id (default `m ≈ 19 MiB`)
384/// because backup codes have higher entropy per character than
385/// passwords and verification runs on every login attempt that
386/// tries a backup code (up to `BACKUP_CODE_COUNT` rows). Full
387/// Argon2id would add latency without strengthening the
388/// security model meaningfully.
389fn backup_code_argon2() -> Result<Argon2<'static>> {
390    let params = Params::new(16 * 1024, 2, 1, None)
391        .map_err(|e| Error::Internal(format!("argon2 params: {e}")))?;
392    Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
393}
394
395/// Generate a fresh batch of backup codes.
396///
397/// Returns `count` strings of the form `XXXX-XXXX` where each
398/// `X` is drawn unbiased from [`BACKUP_CODE_ALPHABET`] using
399/// `rand::thread_rng().gen_range(...)`. The hyphen is purely
400/// visual — at storage time the framework normalises away
401/// hyphens via [`normalise_backup_code`].
402///
403/// **Plaintext lifecycle (D2).** The returned strings are the
404/// only place the plaintext exists. Callers MUST hash via
405/// [`hash_backup_code`] before persisting and MUST render the
406/// plaintext to the user exactly once on the enrolment /
407/// regeneration success page. After that response, the
408/// plaintext is dropped from memory.
409///
410/// Typical caller pattern (R3 commit #6):
411///
412/// ```ignore
413/// let codes = generate_backup_codes(BACKUP_CODE_COUNT);
414/// let hashes: Vec<String> = codes
415///     .iter()
416///     .map(|c| hash_backup_code(c))
417///     .collect::<Result<_>>()?;
418/// // INSERT hashes into rustio_mfa_backup_codes
419/// // RENDER `codes` to the user once, then drop
420/// ```
421#[allow(dead_code)] // call sites land in R3 commit #6 (enrolment) +
422                    // commit when regenerate_backup_codes lands
423                    // internal:
424pub(crate) fn generate_backup_codes(count: usize) -> Vec<String> {
425    let mut rng = rand::thread_rng();
426    let alphabet_len = BACKUP_CODE_ALPHABET.len();
427    (0..count)
428        .map(|_| {
429            // 8 chars + 1 hyphen = 9 chars total.
430            let mut out = String::with_capacity(BACKUP_CODE_LEN + 1);
431            for i in 0..BACKUP_CODE_LEN {
432                if i == 4 {
433                    out.push('-');
434                }
435                let idx = rng.gen_range(0..alphabet_len);
436                out.push(BACKUP_CODE_ALPHABET[idx] as char);
437            }
438            out
439        })
440        .collect()
441}
442
443// internal:
444/// Normalise a user-submitted backup code for hash comparison.
445///
446/// Strips every non-alphanumeric character (so `XXXX-XXXX`,
447/// `XXXXXXXX`, `xxxx xxxx`, `xxxx-xxxx`, etc. all collapse to
448/// the same canonical form) and uppercases. The hash compare
449/// runs on the canonical form.
450///
451/// Idempotent: `normalise(normalise(x)) == normalise(x)`.
452#[allow(dead_code)] // call site lands in R3 commit #8 (consume_backup_code runtime)
453pub(crate) fn normalise_backup_code(input: &str) -> String {
454    input
455        .chars()
456        .filter(|c| c.is_ascii_alphanumeric())
457        .collect::<String>()
458        .to_ascii_uppercase()
459}
460
461// internal:
462/// Hash a backup code with Argon2id (low-memory params).
463///
464/// Generates a fresh 16-byte salt per call from the OS RNG.
465/// Returns the PHC string (`$argon2id$v=19$m=16384,t=2,p=1$...`)
466/// suitable for storage in `rustio_mfa_backup_codes.code_hash`.
467/// The PHC string is self-describing — verification reads the
468/// params from the hash itself.
469///
470/// The caller normalises the plaintext via
471/// [`normalise_backup_code`] before passing to this function so
472/// the user's hyphen / casing variation does not affect the
473/// hash.
474///
475/// **Failure modes** (all `Error::Internal` — the failure is
476/// operator-side at boot, not user-facing):
477///
478/// - Argon2id parameter construction fails (should not happen
479///   with the locked `m / t / p` values).
480/// - Hashing itself fails (rare; usually OOM under contrived
481///   conditions).
482#[allow(dead_code)] // call site lands in R3 commit #6 (enrolment runtime)
483pub(crate) fn hash_backup_code(plaintext: &str) -> Result<String> {
484    let argon2 = backup_code_argon2()?;
485    let salt = SaltString::generate(&mut rand::thread_rng());
486    let hash = argon2
487        .hash_password(plaintext.as_bytes(), &salt)
488        .map_err(|e| Error::Internal(format!("argon2 hash: {e}")))?;
489    Ok(hash.to_string())
490}
491
492// internal:
493/// Verify a normalised backup-code candidate against a stored
494/// PHC hash.
495///
496/// Reads the Argon2 parameters from the PHC string itself, so
497/// the verifier does not need to know the params used at hash
498/// time. Constant-time at the Argon2id primitive level.
499///
500/// Returns `false` for any failure shape — invalid PHC string,
501/// param mismatch, hash mismatch, etc. The caller does not
502/// distinguish causes; the user-facing response is uniform per
503/// `DESIGN_R3_MFA.md` §4.4.
504#[allow(dead_code)] // call site lands in R3 commit #8 (consume_backup_code runtime)
505pub(crate) fn verify_backup_code(plaintext: &str, hash: &str) -> bool {
506    let parsed = match PasswordHash::new(hash) {
507        Ok(p) => p,
508        Err(_) => return false,
509    };
510    Argon2::default()
511        .verify_password(plaintext.as_bytes(), &parsed)
512        .is_ok()
513}
514
515// -----------------------------------------------------------------
516// TOTP — RFC 6238 (R3 commit #5)
517// -----------------------------------------------------------------
518//
519// Hand-rolled HMAC-SHA1-based TOTP per RFC 6238, with the
520// canonical 30-second step interval and 6-digit code format.
521// Pinned by the RFC 6238 Appendix B test vectors (truncated
522// from 8-digit to 6-digit). The framework's TOTP secret is the
523// 20-byte default; longer secrets are accepted but not
524// recommended (no security gain; reduces interop surface).
525//
526// Why hand-rolled rather than `totp-rs`: the framework's
527// dependency-conservative character (one stylesheet, narrow
528// surface). RFC 6238 is small enough to review at the source
529// level; the canonical test vectors give a strong correctness
530// signal. See DESIGN_R3_MFA.md §9.4 + Appendix B for the
531// trade-off discussion.
532
533// internal:
534/// TOTP step number for the given Unix time and step interval.
535///
536/// Pure function: `now_unix / step_seconds` (integer division).
537/// At the canonical 30-second interval, the step value
538/// increments every 30 seconds of wall-clock time. The
539/// `mfa_last_used_step` column persists the highest step value
540/// previously accepted by [`verify_totp`] for replay protection
541/// (D4).
542#[allow(dead_code)] // call sites land in R3 commit #6 (enrolment) + #7 (verify_totp)
543pub fn current_step(now_unix: u64, step_seconds: u64) -> u64 {
544    debug_assert!(step_seconds > 0, "step_seconds must be > 0");
545    now_unix / step_seconds
546}
547
548// internal:
549/// Generate a 6-digit TOTP code for the given secret + step
550/// per RFC 6238 (HMAC-SHA1 + dynamic truncation per RFC 4226
551/// §5.3).
552///
553/// Steps:
554///
555/// 1. Compute `hmac = HMAC-SHA1(secret, step.to_be_bytes())`.
556///    The 8-byte step value is encoded big-endian per RFC 4226.
557/// 2. Read `offset = hmac[19] & 0x0F`. The low nibble of the
558///    last HMAC byte selects a window into the 20-byte HMAC.
559/// 3. Read 4 bytes starting at `offset`, masking the high bit
560///    of the first byte (drops the sign bit per RFC 4226 §5.3).
561/// 4. Modulo `1_000_000` to yield a 6-digit value.
562///
563/// Returns the integer TOTP value in `[0, 999_999]`. Callers
564/// rendering for display should pad with leading zeros via
565/// `format!("{:06}", code)`.
566///
567/// **Infallible.** `Hmac::new_from_slice` accepts any key
568/// length per the HMAC construction; the framework never
569/// produces an invalid secret length internally.
570#[allow(dead_code)] // call sites land in R3 commit #6 (enrolment) + #7 (verify_totp)
571pub fn generate_totp(secret: &[u8], step: u64) -> u32 {
572    // UFCS to disambiguate from `aes_gcm::aead::KeyInit` —
573    // both traits define a `new_from_slice` method.
574    let mut mac = <HmacSha1 as Mac>::new_from_slice(secret).expect("HMAC accepts any key length");
575    mac.update(&step.to_be_bytes());
576    let hash = mac.finalize().into_bytes();
577
578    // Dynamic truncation per RFC 4226 §5.3.
579    let offset = (hash[19] & 0x0F) as usize;
580    let bin_code = u32::from_be_bytes([
581        hash[offset] & 0x7F,
582        hash[offset + 1],
583        hash[offset + 2],
584        hash[offset + 3],
585    ]);
586
587    bin_code % 1_000_000
588}
589
590// internal:
591/// Verify a TOTP candidate within the configured step skew.
592///
593/// Tries the current step ± `skew_steps` against `candidate`.
594/// Returns `Some(step)` of the matching step on success so the
595/// caller can stamp `rustio_users.mfa_last_used_step` for D4
596/// replay protection; returns `None` if no step in the window
597/// matches.
598///
599/// **Replay protection runs at the call site, not here.** This
600/// function reports cryptographic match only. The verify
601/// runtime (R3 commit #7) reads `mfa_last_used_step` from the
602/// user row and rejects matches at or below the stored value
603/// before calling this function.
604///
605/// **Skew window** is symmetric: `[current - skew_steps,
606/// current + skew_steps]` inclusive. Default skew (per
607/// `RecoveryPolicy::mfa_skew_steps`) is 1, giving a 90-second
608/// total acceptance window at the canonical 30-second step.
609#[allow(dead_code)] // call site lands in R3 commit #7 (verify_totp runtime)
610pub(crate) fn verify_totp(
611    secret: &[u8],
612    candidate: u32,
613    now_unix: u64,
614    step_seconds: u64,
615    skew_steps: u32,
616) -> Option<u64> {
617    let current = current_step(now_unix, step_seconds);
618    let skew = i64::from(skew_steps);
619
620    for delta in -skew..=skew {
621        let step_to_try = (current as i64).saturating_add(delta).max(0) as u64;
622        if generate_totp(secret, step_to_try) == candidate {
623            return Some(step_to_try);
624        }
625    }
626    None
627}
628
629// -----------------------------------------------------------------
630// Enrolment runtime (R3 commit #6)
631// -----------------------------------------------------------------
632
633// internal:
634/// A freshly-provisioned TOTP secret + its base32 encoding for
635/// QR / manual-entry display.
636///
637/// **Lifecycle.** The struct's two fields contain the same
638/// secret in two encodings; both are plaintext. The handler
639/// MUST hold this value for the duration of the GET → POST
640/// enrolment hand-off (typically via in-memory session-state
641/// or a short-lived encrypted form-token), then MUST discard
642/// it after [`confirm_enrolment`] runs. Plaintext lives only
643/// in process memory; the at-rest persistence contract (D1)
644/// is enforced inside `confirm_enrolment` via [`wrap_secret`].
645#[allow(dead_code)] // fields read by the enrolment GET handler in a later commit
646pub struct ProvisionedSecret {
647    /// 20 random bytes from the OS RNG. RFC 6238 recommends
648    /// HMAC-SHA1's block size (64 bytes) or output size
649    /// (20 bytes); 20 is the universal authenticator-app
650    /// minimum and matches every standard QR-provisioning URL
651    /// in the wild.
652    pub secret_bytes: Vec<u8>,
653    /// Base32 (RFC 4648) without padding — the form expected
654    /// by `otpauth://totp/...?secret=<this>` URLs and by
655    /// authenticator apps that accept manual entry.
656    pub base32: String,
657}
658
659// internal:
660/// Generate a fresh TOTP secret + its base32 encoding.
661///
662/// Pure (apart from the OS RNG read). Returns 20 raw bytes
663/// drawn from `rand::thread_rng().fill_bytes` plus the
664/// matching base32 string. Callers compose the `otpauth://`
665/// URL elsewhere — this function does not touch the project's
666/// issuer name or the user's email; those concerns live at the
667/// HTTP layer.
668#[allow(dead_code)] // call site lands in the enrolment GET handler
669pub fn provision_secret() -> ProvisionedSecret {
670    let mut bytes = vec![0u8; 20];
671    rand::thread_rng().fill_bytes(&mut bytes);
672    let base32 = base32_encode_no_pad(&bytes);
673    ProvisionedSecret {
674        secret_bytes: bytes,
675        base32,
676    }
677}
678
679// internal:
680/// Build an `otpauth://totp/<issuer>:<account>?...` URL per
681/// the de-facto-standard Google Authenticator Key URI format.
682///
683/// Authenticator apps consume this URL (typically via a QR
684/// code) to provision the secret + verify-side params in one
685/// step. The framework emits the URL; the enrolment template
686/// renders it as a clickable link and a manual-entry fallback.
687///
688/// Format:
689///
690/// ```text
691/// otpauth://totp/<issuer>:<account>?secret=<base32>
692///                                 &issuer=<issuer>
693///                                 &algorithm=SHA1
694///                                 &digits=6
695///                                 &period=<step_seconds>
696/// ```
697///
698/// Both `<issuer>` (in the path) and the `&issuer=` query
699/// param are populated — older authenticator apps parse one
700/// but not the other; including both is the broadest-compat
701/// move per Google's own spec.
702#[allow(dead_code)] // call site lands at the enrolment GET handler (R3 commit #13)
703pub(crate) fn build_otpauth_url(
704    issuer: &str,
705    account: &str,
706    base32_secret: &str,
707    step_seconds: u64,
708) -> String {
709    let issuer_enc = urlencoding::encode(issuer);
710    let account_enc = urlencoding::encode(account);
711    format!(
712        "otpauth://totp/{issuer_enc}:{account_enc}?secret={base32_secret}\
713         &issuer={issuer_enc}&algorithm=SHA1&digits=6&period={step_seconds}"
714    )
715}
716
717/// RFC 4648 base32 encoder (no padding). Hand-rolled rather
718/// than added as a dependency to match the framework's
719/// dependency-conservative character — base32 is ~30 lines and
720/// the alphabet is the persistence contract for the
721/// `otpauth://...?secret=...` URL format.
722///
723/// Pinned by the standard RFC 4648 §10 test vector
724/// `"foobar" -> "MZXW6YTBOI"`.
725fn base32_encode_no_pad(bytes: &[u8]) -> String {
726    const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
727    let mut out = String::with_capacity(bytes.len().div_ceil(5) * 8);
728    let mut buffer: u32 = 0;
729    let mut bits_in_buffer: u8 = 0;
730    for &byte in bytes {
731        buffer = (buffer << 8) | u32::from(byte);
732        bits_in_buffer += 8;
733        while bits_in_buffer >= 5 {
734            bits_in_buffer -= 5;
735            let idx = (buffer >> bits_in_buffer) as usize & 0x1F;
736            out.push(ALPHA[idx] as char);
737        }
738    }
739    if bits_in_buffer > 0 {
740        let idx = (buffer << (5 - bits_in_buffer)) as usize & 0x1F;
741        out.push(ALPHA[idx] as char);
742    }
743    out
744}
745
746// internal:
747/// RFC 4648 base32 decoder (no padding), used to recover the
748/// TOTP secret from the enrolment form's hidden `secret_base32`
749/// field. Inverse of [`base32_encode_no_pad`].
750///
751/// Accepts:
752/// - The 32-character base32 alphabet (A-Z, 2-7), case
753///   insensitive.
754/// - Whitespace, hyphens, and `=` padding chars are silently
755///   stripped before decode (matches what users typically paste
756///   from authenticator apps).
757///
758/// Returns `None` if any non-alphabet character survives the
759/// strip — including the four ambiguity-rejected base32 letters
760/// (0, 1, 8, 9). The handler maps `None` to a uniform "invalid
761/// code" outcome.
762///
763/// Pinned by round-trip tests:
764/// `decode(encode(input)) == input` for arbitrary input.
765#[allow(dead_code)] // call site lands at the enrolment POST handler (R3 commit #13)
766pub(crate) fn base32_decode_no_pad(input: &str) -> Option<Vec<u8>> {
767    let mut buffer: u32 = 0;
768    let mut bits_in_buffer: u8 = 0;
769    let mut out = Vec::with_capacity(input.len() * 5 / 8 + 1);
770    for c in input.chars() {
771        // Tolerate hyphens / spaces / `=` padding so paste-able
772        // strings work without further normalisation at the
773        // call site.
774        if c.is_ascii_whitespace() || c == '-' || c == '=' {
775            continue;
776        }
777        let value: u32 = match c.to_ascii_uppercase() {
778            'A'..='Z' => (c.to_ascii_uppercase() as u32) - ('A' as u32),
779            '2'..='7' => (c as u32) - ('2' as u32) + 26,
780            _ => return None,
781        };
782        buffer = (buffer << 5) | value;
783        bits_in_buffer += 5;
784        if bits_in_buffer >= 8 {
785            bits_in_buffer -= 8;
786            out.push(((buffer >> bits_in_buffer) & 0xFF) as u8);
787        }
788    }
789    // Leftover < 5 bits are zero-padding from the encoder's
790    // tail flush. Accept them silently; rejecting non-zero
791    // leftover bits is over-strict for the otpauth use case.
792    Some(out)
793}
794
795// internal:
796/// Outcome of [`confirm_enrolment`]. Lets the caller render the
797/// right page without embedding HTTP concerns in the runtime
798/// layer.
799#[allow(dead_code)] // variants light up at the HTTP handler in a later commit
800pub enum EnrolOutcome {
801    /// The user's first TOTP code matched the just-provisioned
802    /// secret. The secret has been encrypted and persisted on
803    /// the user row; the 8 backup codes have been hashed and
804    /// inserted into `rustio_mfa_backup_codes`. The plaintext
805    /// backup codes ride in the variant for the one-time
806    /// success-page render (D2).
807    Enrolled { plain_backup_codes: Vec<String> },
808    /// The candidate code did not match the secret within the
809    /// configured skew window. No DB writes occurred; the
810    /// caller can re-render the verify form.
811    InvalidCode,
812    /// The user already has `mfa_enabled = TRUE`. Defensive
813    /// — should not happen if the enrolment handler checks
814    /// state up-front, but the runtime refuses anyway to keep
815    /// the contract honest.
816    AlreadyEnrolled,
817}
818
819// internal:
820/// Confirm a TOTP enrolment by verifying the user's first code
821/// against the provisioned secret, then persisting everything.
822///
823/// **Inputs.**
824///
825/// - `request` — for client-IP capture into the audit row.
826/// - `user_id` — the enrolling user (self-action; actor == target).
827/// - `secret_bytes` — the 20-byte TOTP secret returned by
828///   [`provision_secret`]. The handler holds this across the
829///   GET → POST round-trip and passes it back here.
830/// - `candidate_code` — the 6-digit TOTP code the user typed.
831/// - `step_seconds` — TOTP step interval (locked at 30s per
832///   Appendix B).
833/// - `skew_steps` — accepted skew window (locked at ±1 per
834///   Appendix B).
835/// - `key` — the AES-256-GCM key for at-rest encryption.
836/// - `key_id` — the active `RUSTIO_SECRET_KEY` version, stamped
837///   onto `mfa_secret_key_id` for staged-rotation decryption (D8).
838/// - `correlation_id` — forensic-chain anchor.
839///
840/// **Steps.**
841///
842/// 1. SELECT `mfa_enabled`. If TRUE → `AlreadyEnrolled` (no DB
843///    writes).
844/// 2. `verify_totp`. If no step matches → `InvalidCode` (no DB
845///    writes).
846/// 3. AES-256-GCM encrypt the secret via [`wrap_secret`].
847/// 4. UPDATE `rustio_users` setting `mfa_enabled = TRUE`,
848///    `mfa_secret_ciphertext`, `mfa_secret_key_id`, and
849///    `mfa_last_used_step` (the step that just verified, for D4
850///    replay protection).
851/// 5. [`generate_backup_codes`] (`BACKUP_CODE_COUNT`).
852/// 6. Hash each via [`hash_backup_code`] and INSERT into
853///    `rustio_mfa_backup_codes`.
854/// 7. Emit `AuditEvent::MfaEnabled` with metadata
855///    `{ "backup_codes_count", "key_id" }`.
856///
857/// Returns `EnrolOutcome::Enrolled { plain_backup_codes }`. The
858/// caller renders the codes ONCE on the success page, then
859/// drops the strings. After the response is sent, the only
860/// place the codes exist is the Argon2id hashes in the DB.
861///
862/// **Doctrine 22.** This function does not write `revoked_at`.
863/// The audit emission and DB updates do not pass through
864/// `invalidate_sessions`; enrolment does not invalidate
865/// existing sessions per `DESIGN_R3_MFA.md` §4.1.
866#[allow(dead_code)] // call site lands at the enrolment POST handler in a later commit
867#[allow(clippy::too_many_arguments)]
868pub async fn confirm_enrolment(
869    db: &Db,
870    request: &Request,
871    user_id: i64,
872    secret_bytes: &[u8],
873    candidate_code: u32,
874    step_seconds: u64,
875    skew_steps: u32,
876    key: &MfaKey,
877    key_id: u32,
878    correlation_id: Option<&str>,
879) -> Result<EnrolOutcome> {
880    // 1. Refuse if already enrolled.
881    let already: Option<bool> =
882        sqlx::query_scalar("SELECT mfa_enabled FROM rustio_users WHERE id = $1")
883            .bind(user_id)
884            .fetch_optional(db.pool())
885            .await?;
886    let Some(already) = already else {
887        return Err(Error::NotFound(format!("user {user_id} not found")));
888    };
889    if already {
890        return Ok(EnrolOutcome::AlreadyEnrolled);
891    }
892
893    // 2. Verify the candidate code against the freshly-provisioned
894    //    secret.
895    let now_unix = Utc::now().timestamp().max(0) as u64;
896    let step = match verify_totp(
897        secret_bytes,
898        candidate_code,
899        now_unix,
900        step_seconds,
901        skew_steps,
902    ) {
903        Some(step) => step,
904        None => return Ok(EnrolOutcome::InvalidCode),
905    };
906
907    // 3. Encrypt the secret for at-rest storage (D1).
908    let ciphertext = wrap_secret(secret_bytes, key);
909
910    // 4. Update the user row. Stamps mfa_last_used_step with the
911    //    step that just verified, so the very first verify_totp
912    //    after enrolment cannot replay this same code (D4).
913    sqlx::query(
914        "UPDATE rustio_users \
915         SET mfa_enabled = TRUE, \
916             mfa_secret_ciphertext = $1, \
917             mfa_secret_key_id = $2, \
918             mfa_last_used_step = $3 \
919         WHERE id = $4",
920    )
921    .bind(&ciphertext)
922    .bind(key_id as i32)
923    .bind(step as i64)
924    .bind(user_id)
925    .execute(db.pool())
926    .await?;
927
928    // 5. Generate the backup-code batch.
929    let plain_codes = generate_backup_codes(BACKUP_CODE_COUNT);
930
931    // 6. Hash + insert each. Normalisation runs at consume time
932    //    (the user types XXXX-XXXX with the hyphen); the hashes
933    //    persist the canonical form.
934    for code in &plain_codes {
935        let normalised = normalise_backup_code(code);
936        let hash = hash_backup_code(&normalised)?;
937        sqlx::query("INSERT INTO rustio_mfa_backup_codes (user_id, code_hash) VALUES ($1, $2)")
938            .bind(user_id)
939            .bind(&hash)
940            .execute(db.pool())
941            .await?;
942    }
943
944    // 7. Audit emit.
945    let metadata = serde_json::json!({
946        "backup_codes_count": BACKUP_CODE_COUNT,
947        "key_id": key_id,
948    });
949    let ip = client_ip(request);
950    let mut entry = LogEntry::new(user_id, ActionType::Update, "users", user_id)
951        .with_event(AuditEvent::MfaEnabled)
952        .with_actor(user_id);
953    entry.correlation_id = correlation_id;
954    entry.ip_address = ip.as_deref();
955    entry.metadata = Some(metadata);
956    entry.summary = "MFA enabled (TOTP + 8 backup codes)".to_string();
957    audit_record(db, entry).await?;
958
959    Ok(EnrolOutcome::Enrolled {
960        plain_backup_codes: plain_codes,
961    })
962}
963
964// -----------------------------------------------------------------
965// Verification runtime — TOTP login second factor (R3 commit #7)
966// -----------------------------------------------------------------
967
968// internal:
969/// Outcome of [`verify_totp_for_user`]. Lets the verify
970/// handler render the right page without embedding HTTP
971/// concerns in the runtime layer.
972///
973/// All four variants collapse to a uniform user-facing
974/// response per `DESIGN_R3_MFA.md` §3.1 disclosure rules —
975/// the handler must NOT render different copy for `Replay`
976/// vs `Invalid` vs `NotEnrolled`. The variant distinctions
977/// exist for forensic logging, future audit emission, and
978/// internal debugging only.
979#[allow(dead_code)] // variants light up at the verify handler in a later commit
980pub enum VerifyOutcome {
981    /// The candidate code matched within the skew window AND
982    /// the matched step is strictly greater than
983    /// `mfa_last_used_step`. The runtime has stamped the new
984    /// step; the caller proceeds with trust escalation
985    /// (mint a fresh `mfa_verified` session row, revoke the
986    /// pending row, swap the cookie).
987    Verified { step_used: u64 },
988    /// The candidate matched cryptographically but the matched
989    /// step is at or below `mfa_last_used_step` — D4 replay
990    /// protection. Cause: a network-captured code, a
991    /// double-submit, or clock drift on the user's device.
992    /// Caller MUST NOT trust-escalate; user-facing copy is
993    /// uniform with `Invalid`.
994    Replay { last_used_step: u64 },
995    /// The candidate did not match within the configured skew
996    /// window, or the candidate string was not parseable as a
997    /// 6-digit number.
998    Invalid,
999    /// The user row exists but `mfa_enabled = FALSE`. Should
1000    /// not happen if the verify handler checks state up-front,
1001    /// but the runtime refuses anyway to keep the contract
1002    /// honest.
1003    NotEnrolled,
1004}
1005
1006// internal:
1007/// Verify a TOTP candidate for an enrolled user.
1008///
1009/// **Inputs.**
1010///
1011/// - `user_id` — the user being challenged.
1012/// - `candidate_code_str` — the 6-digit string the user typed.
1013///   Whitespace-trimmed and parsed to `u32`; invalid input
1014///   collapses to `VerifyOutcome::Invalid`.
1015/// - `step_seconds` — TOTP step interval (locked at 30s per
1016///   Appendix B).
1017/// - `skew_steps` — accepted skew window (locked at ±1 per
1018///   Appendix B).
1019/// - `key` — the AES-256-GCM key for at-rest decryption.
1020///   Future: when the `MfaSecretKeyResolver` trait lands,
1021///   this becomes a resolver lookup keyed by the row's
1022///   `mfa_secret_key_id`. For now (R3 commit #7) the
1023///   framework assumes a single active key (`key_id = 1`).
1024///
1025/// **Steps.**
1026///
1027/// 1. Parse `candidate_code_str` as a 6-digit `u32`. Failure
1028///    → `Invalid`.
1029/// 2. SELECT `mfa_enabled`, `mfa_secret_ciphertext`,
1030///    `mfa_last_used_step` from `rustio_users`. Missing row
1031///    → `Error::NotFound`.
1032/// 3. If `!mfa_enabled` → `NotEnrolled`.
1033/// 4. If ciphertext is `NULL` while `mfa_enabled = TRUE` →
1034///    `Error::Internal` (corrupted state — cannot happen
1035///    via the framework's own writes).
1036/// 5. [`unwrap_secret`] decrypts the ciphertext under `key`.
1037///    Decryption failure surfaces as `Error::Internal` (key
1038///    mismatch or tampering — operator-side recovery).
1039/// 6. [`verify_totp`] against the secret. No match within the
1040///    skew window → `Invalid`.
1041/// 7. **D4 replay check.** If the matched step is at or below
1042///    `mfa_last_used_step` → `Replay`. The previously-stored
1043///    step value rides in the variant for forensic logging.
1044/// 8. UPDATE `mfa_last_used_step` to the just-verified step.
1045/// 9. Return `Verified { step_used }`.
1046///
1047/// **No audit row.** TOTP success is captured via the
1048/// session-promotion `parent_session_id` lineage per §8.3.
1049/// Backup-code consume DOES emit `AuditEvent::MfaCodeConsumed`
1050/// (R3 commit #8).
1051///
1052/// **Doctrine 22.** This function does not write `revoked_at`.
1053/// The trust-escalation that follows (mint fresh
1054/// `mfa_verified` row + revoke pending row + swap cookie)
1055/// runs through `auth::sessions::invalidate_sessions` at the
1056/// handler level — not here.
1057#[allow(dead_code)] // call site lands at the verify POST handler in a later commit
1058pub async fn verify_totp_for_user(
1059    db: &Db,
1060    user_id: i64,
1061    candidate_code_str: &str,
1062    step_seconds: u64,
1063    skew_steps: u32,
1064    key: &MfaKey,
1065) -> Result<VerifyOutcome> {
1066    use sqlx::Row as _;
1067
1068    // 1. Parse the candidate as a 6-digit u32.
1069    let candidate = match candidate_code_str.trim().parse::<u32>() {
1070        Ok(n) if n < 1_000_000 => n,
1071        _ => return Ok(VerifyOutcome::Invalid),
1072    };
1073
1074    // 2. Read MFA state from the user row.
1075    let row = sqlx::query(
1076        "SELECT mfa_enabled, mfa_secret_ciphertext, mfa_last_used_step \
1077         FROM rustio_users WHERE id = $1",
1078    )
1079    .bind(user_id)
1080    .fetch_optional(db.pool())
1081    .await?;
1082    let row = row.ok_or_else(|| Error::NotFound(format!("user {user_id} not found")))?;
1083
1084    let mfa_enabled: bool = row.try_get("mfa_enabled")?;
1085    if !mfa_enabled {
1086        return Ok(VerifyOutcome::NotEnrolled);
1087    }
1088
1089    let ciphertext: Option<Vec<u8>> = row.try_get("mfa_secret_ciphertext")?;
1090    let last_used_step: Option<i64> = row.try_get("mfa_last_used_step")?;
1091
1092    let ciphertext = ciphertext.ok_or_else(|| {
1093        Error::Internal(format!(
1094            "user {user_id} has mfa_enabled=TRUE but mfa_secret_ciphertext IS NULL"
1095        ))
1096    })?;
1097
1098    // 3. Decrypt the secret. Failure here is operator-side:
1099    //    wrong key (rotation issue) or tampered ciphertext (DB
1100    //    attack). Both surface as Error::Internal; the user
1101    //    sees a uniform error response from the handler.
1102    let secret_bytes = unwrap_secret(&ciphertext, key)?;
1103
1104    // 4. Verify the candidate against the secret + skew window.
1105    let now_unix = Utc::now().timestamp().max(0) as u64;
1106    let step = match verify_totp(&secret_bytes, candidate, now_unix, step_seconds, skew_steps) {
1107        Some(step) => step,
1108        None => return Ok(VerifyOutcome::Invalid),
1109    };
1110
1111    // 5. D4 replay protection. mfa_last_used_step is monotonic
1112    //    per user; a TOTP code from a step ≤ the stored value
1113    //    is rejected even if it just verified cryptographically.
1114    //    A NULL last-used-step (theoretically unreachable after
1115    //    confirm_enrolment stamps it; included defensively) is
1116    //    treated as -1 so any non-negative step value passes
1117    //    the comparison.
1118    let last = last_used_step.unwrap_or(-1);
1119    if (step as i64) <= last {
1120        return Ok(VerifyOutcome::Replay {
1121            last_used_step: last.max(0) as u64,
1122        });
1123    }
1124
1125    // 6. Stamp the new step. Subsequent verifications with the
1126    //    same step value (replay) will be rejected at step 5
1127    //    above.
1128    sqlx::query("UPDATE rustio_users SET mfa_last_used_step = $1 WHERE id = $2")
1129        .bind(step as i64)
1130        .bind(user_id)
1131        .execute(db.pool())
1132        .await?;
1133
1134    Ok(VerifyOutcome::Verified { step_used: step })
1135}
1136
1137// -----------------------------------------------------------------
1138// Backup-code consume runtime (R3 commit #8)
1139// -----------------------------------------------------------------
1140
1141// internal:
1142/// Outcome of [`consume_backup_code`]. Lets the verify handler
1143/// render the right page without embedding HTTP concerns in the
1144/// runtime layer.
1145///
1146/// All variants collapse to a uniform user-facing response per
1147/// `DESIGN_R3_MFA.md` §3.1 disclosure rules — the handler MUST
1148/// NOT distinguish `Invalid` from `AlreadyUsed` from
1149/// `NotEnrolled` in the rendered copy. The variant distinctions
1150/// exist for forensic logging and internal debugging only.
1151#[allow(dead_code)] // variants light up at the verify handler in a later commit
1152pub enum BackupConsumeOutcome {
1153    /// The candidate matched an unused backup code. The row has
1154    /// been atomically marked `used_at = NOW()`; the audit row
1155    /// has been emitted. The caller proceeds with trust
1156    /// escalation (mint fresh `mfa_verified` session row, revoke
1157    /// the pending row, swap the cookie).
1158    Consumed { code_id: i64, remaining: u32 },
1159    /// The candidate did not match any unused row, OR the input
1160    /// failed normalisation, OR a race against a parallel
1161    /// consume request lost. Uniform copy with `AlreadyUsed`
1162    /// per the disclosure rule.
1163    Invalid,
1164    /// The user row exists but `mfa_enabled = FALSE`. Should
1165    /// not happen if the verify handler checks state up-front,
1166    /// but the runtime refuses anyway to keep the contract
1167    /// honest.
1168    NotEnrolled,
1169    /// Reserved for the case where the SELECT widens beyond
1170    /// `WHERE used_at IS NULL`. The current SELECT filters at
1171    /// the index level so this variant is unreachable; it is
1172    /// retained for forward-compatibility per
1173    /// `DESIGN_R3_MFA.md` §9.2.
1174    #[allow(dead_code)]
1175    AlreadyUsed,
1176}
1177
1178// internal:
1179/// Consume a backup code as the second factor on the verify
1180/// flow.
1181///
1182/// **Inputs.**
1183///
1184/// - `request` — for client-IP capture into the audit row.
1185/// - `user_id` — the user being challenged.
1186/// - `candidate_str` — the raw input the user typed. Hyphen
1187///   and casing are normalised via
1188///   [`normalise_backup_code`] before hash compare.
1189/// - `via` — caller context (`"login"` or `"reauth"`)
1190///   recorded into `metadata.via` per §8.2.
1191/// - `correlation_id` — forensic-chain anchor.
1192///
1193/// **Steps.**
1194///
1195/// 1. Normalise the candidate. Empty after normalisation →
1196///    `Invalid`.
1197/// 2. SELECT `mfa_enabled` from `rustio_users`. Missing row →
1198///    `Error::NotFound`. `mfa_enabled = FALSE` → `NotEnrolled`.
1199/// 3. SELECT `id, code_hash` from `rustio_mfa_backup_codes`
1200///    WHERE `user_id = ? AND used_at IS NULL`. The partial
1201///    index makes this an index seek scoped to ≤
1202///    `BACKUP_CODE_COUNT` rows.
1203/// 4. **Constant-time iteration** over the rows. Argon2id
1204///    verify each candidate; do NOT break on first match. The
1205///    matched id is recorded once; subsequent matches (cannot
1206///    happen given fresh-salt-per-row) are ignored. Iterating
1207///    every row prevents a timing leak about the matching
1208///    index.
1209/// 5. No match → `Invalid`.
1210/// 6. **Atomic single-use UPDATE.** `UPDATE … SET used_at =
1211///    NOW() WHERE id = $1 AND used_at IS NULL`. If
1212///    `rows_affected = 0`, another concurrent request consumed
1213///    the same code first; treated as `Invalid` for uniform
1214///    user-facing response (D7 protected at the SQL level).
1215/// 7. Count remaining unused codes for the audit metadata +
1216///    caller's render decision (the handler may flash a
1217///    "regenerate" warning when `remaining ≤ 2`).
1218/// 8. Emit `AuditEvent::MfaCodeConsumed` with metadata
1219///    `{ code_id, remaining_codes, via }`.
1220///
1221/// **Doctrine 22.** This function does not write `revoked_at`.
1222/// The trust escalation that follows on `Consumed` runs
1223/// through `auth::sessions::invalidate_sessions` at the
1224/// handler level — not here.
1225///
1226/// **Audit row emits inside the function.** Unlike
1227/// [`verify_totp_for_user`] which is silent (TOTP success is
1228/// captured via session-promotion lineage), backup-code
1229/// consume is an out-of-band recovery event worth surfacing in
1230/// the forensic chain.
1231#[allow(dead_code)] // call site lands at the verify POST handler in a later commit
1232pub async fn consume_backup_code(
1233    db: &Db,
1234    request: &Request,
1235    user_id: i64,
1236    candidate_str: &str,
1237    via: &'static str,
1238    correlation_id: Option<&str>,
1239) -> Result<BackupConsumeOutcome> {
1240    use sqlx::Row as _;
1241
1242    // 1. Normalise.
1243    let candidate = normalise_backup_code(candidate_str);
1244    if candidate.is_empty() {
1245        return Ok(BackupConsumeOutcome::Invalid);
1246    }
1247
1248    // 2. Verify enrolment.
1249    let mfa_enabled: Option<bool> =
1250        sqlx::query_scalar("SELECT mfa_enabled FROM rustio_users WHERE id = $1")
1251            .bind(user_id)
1252            .fetch_optional(db.pool())
1253            .await?;
1254    let mfa_enabled =
1255        mfa_enabled.ok_or_else(|| Error::NotFound(format!("user {user_id} not found")))?;
1256    if !mfa_enabled {
1257        return Ok(BackupConsumeOutcome::NotEnrolled);
1258    }
1259
1260    // 3. SELECT all unused candidates.
1261    let rows = sqlx::query(
1262        "SELECT id, code_hash FROM rustio_mfa_backup_codes \
1263         WHERE user_id = $1 AND used_at IS NULL \
1264         ORDER BY id",
1265    )
1266    .bind(user_id)
1267    .fetch_all(db.pool())
1268    .await?;
1269
1270    // 4. Constant-time iteration. Verify against every row even
1271    //    after a match; record the first matched id only. Per
1272    //    §4.4: do not break on first match — leaks timing about
1273    //    candidate ordering otherwise.
1274    let mut matched_id: Option<i64> = None;
1275    for row in &rows {
1276        let id: i64 = row.try_get("id")?;
1277        let hash: String = row.try_get("code_hash")?;
1278        if verify_backup_code(&candidate, &hash) && matched_id.is_none() {
1279            matched_id = Some(id);
1280        }
1281    }
1282
1283    let Some(matched_id) = matched_id else {
1284        return Ok(BackupConsumeOutcome::Invalid);
1285    };
1286
1287    // 5. Atomic single-use UPDATE. The `AND used_at IS NULL`
1288    //    clause guards against a parallel consume of the same
1289    //    code: only one of two concurrent requests sees
1290    //    rows_affected = 1; the loser collapses to Invalid for
1291    //    uniform user-facing response.
1292    let result = sqlx::query(
1293        "UPDATE rustio_mfa_backup_codes \
1294         SET used_at = NOW() \
1295         WHERE id = $1 AND used_at IS NULL",
1296    )
1297    .bind(matched_id)
1298    .execute(db.pool())
1299    .await?;
1300
1301    if result.rows_affected() == 0 {
1302        return Ok(BackupConsumeOutcome::Invalid);
1303    }
1304
1305    // 6. Count remaining unused codes for the metadata.
1306    let remaining: i64 = sqlx::query_scalar(
1307        "SELECT COUNT(*) FROM rustio_mfa_backup_codes \
1308         WHERE user_id = $1 AND used_at IS NULL",
1309    )
1310    .bind(user_id)
1311    .fetch_one(db.pool())
1312    .await?;
1313    let remaining = remaining.max(0) as u32;
1314
1315    // 7. Emit AuditEvent::MfaCodeConsumed.
1316    let metadata = serde_json::json!({
1317        "code_id": matched_id,
1318        "remaining_codes": remaining,
1319        "via": via,
1320    });
1321    let ip = client_ip(request);
1322    let mut entry = LogEntry::new(user_id, ActionType::Update, "users", user_id)
1323        .with_event(AuditEvent::MfaCodeConsumed)
1324        .with_actor(user_id);
1325    entry.correlation_id = correlation_id;
1326    entry.ip_address = ip.as_deref();
1327    entry.metadata = Some(metadata);
1328    entry.summary = format!("backup code consumed via {via}; {remaining} remaining");
1329    audit_record(db, entry).await?;
1330
1331    Ok(BackupConsumeOutcome::Consumed {
1332        code_id: matched_id,
1333        remaining,
1334    })
1335}
1336
1337// -----------------------------------------------------------------
1338// Disable MFA runtime (R3 commit #9)
1339// -----------------------------------------------------------------
1340
1341// internal:
1342/// Outcome of [`disable_mfa`]. Lets the disable handler render
1343/// the right page without embedding HTTP concerns in the
1344/// runtime layer.
1345#[allow(dead_code)] // variants light up at the disable handler in a later commit
1346pub enum DisableOutcome {
1347    /// MFA disabled successfully. The user row's four MFA
1348    /// columns are reset (`mfa_enabled = FALSE`, the secret +
1349    /// key id + last-used step all NULL). The backup-code rows
1350    /// are deleted. All sessions for the user are revoked with
1351    /// `SessionInvalidationReason::MfaDisabled`. The
1352    /// `MfaDisabled` audit row is emitted.
1353    Disabled { sessions_revoked: usize },
1354    /// The user row exists but `mfa_enabled = FALSE`. No
1355    /// writes; defensive against accidental double-disable.
1356    NotEnrolled,
1357    /// Reserved for the case where the framework's active
1358    /// `MfaPolicy` requires MFA for this user's role and the
1359    /// runtime is called with policy enforcement enabled. The
1360    /// current runtime does NOT consult the policy — the
1361    /// handler is responsible for refusing self-disable under
1362    /// `MfaPolicy::Required` BEFORE invoking this function. The
1363    /// variant is retained per `DESIGN_R3_MFA.md` §9.2 for
1364    /// forward-compat when a future commit pushes policy
1365    /// enforcement into the runtime layer.
1366    #[allow(dead_code)]
1367    PolicyRequired,
1368}
1369
1370// internal:
1371/// Disable MFA for a user.
1372///
1373/// **Inputs.**
1374///
1375/// - `request` — for client-IP capture into the audit row.
1376/// - `user_id` — the user disabling their own MFA (self-action).
1377///   Admin-driven disable (`MfaDisabledByOther` reason) ships
1378///   in R4 CLI emergency recovery per `DESIGN_R3_MFA.md` §1.2;
1379///   this runtime handles the self-disable path only.
1380/// - `correlation_id` — forensic-chain anchor.
1381///
1382/// **Steps.**
1383///
1384/// 1. SELECT `mfa_enabled`. Missing row → `Error::NotFound`.
1385///    `mfa_enabled = FALSE` → `NotEnrolled` (no writes).
1386/// 2. SELECT COUNT(*) of existing backup-code rows for the
1387///    `previous_backup_codes_count` audit metadata.
1388/// 3. UPDATE `rustio_users` clearing all four MFA columns
1389///    atomically: `mfa_enabled = FALSE`, ciphertext / key_id /
1390///    last_used_step → NULL.
1391/// 4. DELETE all backup-code rows for the user. The user-row
1392///    UPDATE alone would leave orphan rows that the
1393///    `ON DELETE CASCADE` clause does not cover (the user row
1394///    survives the disable; only the backup-code rows
1395///    disappear).
1396/// 5. `invalidate_sessions(SessionTarget::User { user_id },
1397///    SessionInvalidationReason::MfaDisabled)` — Doctrine 22's
1398///    sole writer of `revoked_at`. Every session for this user
1399///    revokes; the current device included. After disable,
1400///    the user signs back in with password only.
1401/// 6. Emit `AuditEvent::MfaDisabled` with metadata
1402///    `{ reason: "self_disabled", previous_backup_codes_count }`
1403///    per §8.2.
1404///
1405/// **Doctrine 22.** This function delegates revocation to
1406/// `auth::sessions::invalidate_sessions` — does NOT write
1407/// `revoked_at` directly. The single-writer invariant
1408/// survives. The grep proof remains intact.
1409///
1410/// **Audit emits AFTER invalidation succeeds.** Audit captures
1411/// what actually happened; a partial success that fails
1412/// invalidation never produces an audit row.
1413#[allow(dead_code)] // call site lands at the disable POST handler in a later commit
1414pub async fn disable_mfa(
1415    db: &Db,
1416    request: &Request,
1417    user_id: i64,
1418    correlation_id: Option<&str>,
1419) -> Result<DisableOutcome> {
1420    // 1. Confirm enrolment.
1421    let mfa_enabled: Option<bool> =
1422        sqlx::query_scalar("SELECT mfa_enabled FROM rustio_users WHERE id = $1")
1423            .bind(user_id)
1424            .fetch_optional(db.pool())
1425            .await?;
1426    let mfa_enabled =
1427        mfa_enabled.ok_or_else(|| Error::NotFound(format!("user {user_id} not found")))?;
1428    if !mfa_enabled {
1429        return Ok(DisableOutcome::NotEnrolled);
1430    }
1431
1432    // 2. Count existing backup-code rows for the audit row's
1433    //    metadata.previous_backup_codes_count.
1434    let previous_count: i64 =
1435        sqlx::query_scalar("SELECT COUNT(*) FROM rustio_mfa_backup_codes WHERE user_id = $1")
1436            .bind(user_id)
1437            .fetch_one(db.pool())
1438            .await?;
1439    let previous_count = previous_count.max(0) as u32;
1440
1441    // 3. Clear all four MFA columns on the user row in one
1442    //    UPDATE. Atomicity is per-row at the SQL level — readers
1443    //    of rustio_users will never observe a half-disabled
1444    //    state (e.g. mfa_enabled = FALSE while
1445    //    mfa_secret_ciphertext still contains ciphertext).
1446    sqlx::query(
1447        "UPDATE rustio_users \
1448         SET mfa_enabled = FALSE, \
1449             mfa_secret_ciphertext = NULL, \
1450             mfa_secret_key_id = NULL, \
1451             mfa_last_used_step = NULL \
1452         WHERE id = $1",
1453    )
1454    .bind(user_id)
1455    .execute(db.pool())
1456    .await?;
1457
1458    // 4. Delete backup-code rows. The schema's ON DELETE
1459    //    CASCADE handles user-row deletion; this DELETE handles
1460    //    disable-without-user-deletion (the common case).
1461    sqlx::query("DELETE FROM rustio_mfa_backup_codes WHERE user_id = $1")
1462        .bind(user_id)
1463        .execute(db.pool())
1464        .await?;
1465
1466    // 5. Revoke every session via the centralised single writer
1467    //    of revoked_at (Doctrine 22).
1468    let invalidation = invalidate_sessions(
1469        db,
1470        SessionTarget::User { user_id },
1471        SessionInvalidationReason::MfaDisabled,
1472    )
1473    .await?;
1474    let sessions_revoked = invalidation.revoked_session_ids.len();
1475
1476    // 6. Audit emit AFTER all DB writes succeed (D8).
1477    let metadata = serde_json::json!({
1478        "reason": "self_disabled",
1479        "previous_backup_codes_count": previous_count,
1480        "sessions_revoked": sessions_revoked,
1481    });
1482    let ip = client_ip(request);
1483    let mut entry = LogEntry::new(user_id, ActionType::Update, "users", user_id)
1484        .with_event(AuditEvent::MfaDisabled)
1485        .with_actor(user_id);
1486    entry.correlation_id = correlation_id;
1487    entry.ip_address = ip.as_deref();
1488    entry.metadata = Some(metadata);
1489    entry.summary = format!(
1490        "MFA self-disabled; {previous_count} backup codes deleted; \
1491         {sessions_revoked} sessions revoked"
1492    );
1493    audit_record(db, entry).await?;
1494
1495    Ok(DisableOutcome::Disabled { sessions_revoked })
1496}
1497
1498// -----------------------------------------------------------------
1499// Backup-code regenerate runtime (R3 commit #10)
1500// -----------------------------------------------------------------
1501
1502// internal:
1503/// Outcome of [`regenerate_backup_codes`]. Lets the regenerate
1504/// handler render the right page without embedding HTTP
1505/// concerns in the runtime layer.
1506#[allow(dead_code)] // variants light up at the regenerate handler in a later commit
1507pub enum RegenOutcome {
1508    /// A fresh batch of `BACKUP_CODE_COUNT` codes was generated
1509    /// inside an atomic transaction (D3). The old batch — all
1510    /// rows for this user — was deleted in the same transaction
1511    /// and is unrecoverable from the moment the commit landed.
1512    /// `previous_codes_invalidated` is the count of rows the
1513    /// DELETE removed (used + unused combined).
1514    /// `plain_backup_codes` carries the freshly-generated
1515    /// plaintext for the one-time success-page render (D2);
1516    /// the caller MUST drop them after the response.
1517    Regenerated {
1518        plain_backup_codes: Vec<String>,
1519        previous_codes_invalidated: u32,
1520    },
1521    /// The user row exists but `mfa_enabled = FALSE`. No
1522    /// writes; regenerating codes for a non-enrolled user is
1523    /// a no-op refused by the runtime.
1524    NotEnrolled,
1525}
1526
1527// internal:
1528/// Regenerate the backup-code batch for a user atomically.
1529///
1530/// **Inputs.**
1531///
1532/// - `request` — for client-IP capture into the audit row.
1533/// - `user_id` — the user regenerating their own batch
1534///   (self-action; re-auth gating is the handler's concern).
1535/// - `correlation_id` — forensic-chain anchor.
1536///
1537/// **Steps.**
1538///
1539/// 1. Generate `BACKUP_CODE_COUNT` plaintext codes via
1540///    [`generate_backup_codes`] and hash each via
1541///    [`hash_backup_code`] (Argon2id, low-memory params). The
1542///    Argon2id hashing is slow; runs OUTSIDE the transaction
1543///    so the row lock is held only for the brief DELETE +
1544///    INSERT window.
1545/// 2. BEGIN TRANSACTION.
1546/// 3. `SELECT mfa_enabled FROM rustio_users WHERE id = $1 FOR UPDATE`.
1547///    The `FOR UPDATE` row lock serialises concurrent regenerate
1548///    calls for the same user — without it, two simultaneous
1549///    requests would each DELETE then INSERT, leaving 16
1550///    active codes (the union of both batches). Missing row →
1551///    `Error::NotFound` (rolled back). `mfa_enabled = FALSE` →
1552///    `NotEnrolled` (rolled back; no writes).
1553/// 4. `SELECT COUNT(*)` of existing rows for
1554///    `metadata.previous_codes_invalidated`. Includes used +
1555///    unused since the DELETE removes both.
1556/// 5. `DELETE FROM rustio_mfa_backup_codes WHERE user_id = ?`.
1557///    Wipes the old batch.
1558/// 6. INSERT each freshly-hashed code.
1559/// 7. COMMIT.
1560/// 8. Emit `AuditEvent::BackupCodesRegenerated` with metadata
1561///    `{ previous_codes_invalidated, new_codes_count }` per §8.2.
1562/// 9. Return `Regenerated { plain_backup_codes,
1563///    previous_codes_invalidated }`. The caller renders the
1564///    plaintext codes ONCE on the success page, then drops
1565///    them.
1566///
1567/// **Doctrine 22.** This function does not write `revoked_at`.
1568/// Regeneration does not invalidate sessions per §4.5 — the
1569/// user's existing mfa_verified sessions remain valid; only
1570/// the backup-code rows are replaced.
1571///
1572/// **D3 atomicity proof.** The DELETE + INSERTs run inside a
1573/// single sqlx transaction. From the moment `tx.commit()`
1574/// returns, the only backup codes for this user are the new
1575/// 8; the old batch's hashes are gone from the database. A
1576/// crash between DELETE and COMMIT rolls back via Postgres's
1577/// MVCC — both states (old batch intact / new batch active)
1578/// are observable; no in-between is.
1579#[allow(dead_code)] // call site lands at the regenerate POST handler in a later commit
1580pub async fn regenerate_backup_codes(
1581    db: &Db,
1582    request: &Request,
1583    user_id: i64,
1584    correlation_id: Option<&str>,
1585) -> Result<RegenOutcome> {
1586    // 1. Generate + hash OUTSIDE the transaction. Argon2id
1587    //    hashing is the slowest step; holding a row lock
1588    //    across it would pessimistically block other reads of
1589    //    rustio_users for this user.
1590    let plain_codes = generate_backup_codes(BACKUP_CODE_COUNT);
1591    let hashes: Vec<String> = plain_codes
1592        .iter()
1593        .map(|c| {
1594            let normalised = normalise_backup_code(c);
1595            hash_backup_code(&normalised)
1596        })
1597        .collect::<Result<Vec<String>>>()?;
1598
1599    // 2-7. Atomic transaction.
1600    let mut tx = db.pool().begin().await?;
1601
1602    // 3. SELECT … FOR UPDATE — serialises concurrent regenerates.
1603    let mfa_enabled: Option<bool> =
1604        sqlx::query_scalar("SELECT mfa_enabled FROM rustio_users WHERE id = $1 FOR UPDATE")
1605            .bind(user_id)
1606            .fetch_optional(&mut *tx)
1607            .await?;
1608    let mfa_enabled =
1609        mfa_enabled.ok_or_else(|| Error::NotFound(format!("user {user_id} not found")))?;
1610    if !mfa_enabled {
1611        // tx auto-rollbacks on drop.
1612        return Ok(RegenOutcome::NotEnrolled);
1613    }
1614
1615    // 4. Count the about-to-be-invalidated rows for the audit
1616    //    metadata. SELECT runs against the same snapshot as
1617    //    the DELETE below since we are inside the same tx.
1618    let previous_count: i64 =
1619        sqlx::query_scalar("SELECT COUNT(*) FROM rustio_mfa_backup_codes WHERE user_id = $1")
1620            .bind(user_id)
1621            .fetch_one(&mut *tx)
1622            .await?;
1623    let previous_count = previous_count.max(0) as u32;
1624
1625    // 5. Wipe the old batch.
1626    sqlx::query("DELETE FROM rustio_mfa_backup_codes WHERE user_id = $1")
1627        .bind(user_id)
1628        .execute(&mut *tx)
1629        .await?;
1630
1631    // 6. Insert the new hashes. One round-trip per row keeps the
1632    //    code simple at the cost of N inserts; BACKUP_CODE_COUNT
1633    //    is 8, so the overhead is negligible (well under the
1634    //    Argon2id hashing cost we already paid above).
1635    for hash in &hashes {
1636        sqlx::query("INSERT INTO rustio_mfa_backup_codes (user_id, code_hash) VALUES ($1, $2)")
1637            .bind(user_id)
1638            .bind(hash)
1639            .execute(&mut *tx)
1640            .await?;
1641    }
1642
1643    // 7. Commit. D3 atomicity guaranteed from here forward.
1644    tx.commit().await?;
1645
1646    // 8. Audit emit AFTER commit succeeds (D8).
1647    let metadata = serde_json::json!({
1648        "previous_codes_invalidated": previous_count,
1649        "new_codes_count": BACKUP_CODE_COUNT,
1650    });
1651    let ip = client_ip(request);
1652    let mut entry = LogEntry::new(user_id, ActionType::Update, "users", user_id)
1653        .with_event(AuditEvent::BackupCodesRegenerated)
1654        .with_actor(user_id);
1655    entry.correlation_id = correlation_id;
1656    entry.ip_address = ip.as_deref();
1657    entry.metadata = Some(metadata);
1658    entry.summary = format!(
1659        "backup codes regenerated; {previous_count} previous invalidated; \
1660         {BACKUP_CODE_COUNT} new codes issued"
1661    );
1662    audit_record(db, entry).await?;
1663
1664    Ok(RegenOutcome::Regenerated {
1665        plain_backup_codes: plain_codes,
1666        previous_codes_invalidated: previous_count,
1667    })
1668}
1669
1670// -----------------------------------------------------------------
1671// Trust-escalation primitive (R3 commit #11)
1672// -----------------------------------------------------------------
1673
1674// internal:
1675/// Promote a session from `authenticated` to `mfa_verified` via
1676/// token rotation per `DESIGN_SESSIONS.md` §11 + Doctrine 17.
1677///
1678/// Called by the verify POST handler after either
1679/// [`verify_totp_for_user`] or [`consume_backup_code`] returns
1680/// success. The function:
1681///
1682/// 1. Mints a fresh session row with:
1683///    - new random `token` + `token_hash`,
1684///    - `trust_level = 'mfa_verified'`,
1685///    - `parent_session_id = current_session_id` (the row that
1686///      was just MFA-verified — establishes the audit lineage),
1687///    - `user_id` unchanged,
1688///    - `expires_at = NOW() + 14 days`.
1689/// 2. Revokes the parent row via
1690///    `auth::sessions::invalidate_sessions` with
1691///    `SessionInvalidationReason::TrustEscalation`. Doctrine 22:
1692///    no direct `revoked_at` write here; the centralised
1693///    invalidator owns that.
1694/// 3. Returns the new plaintext token. The caller (handler)
1695///    sets it as the framework's session cookie, replacing the
1696///    pre-MFA token.
1697///
1698/// **Ordering rationale.** The new row is INSERTed before the
1699/// parent is revoked. A crash between the two operations leaves
1700/// the user with two active session rows (old + new) rather
1701/// than zero — the more recoverable failure mode. The next
1702/// request authenticates against either row; an over-permissive
1703/// transient state is preferable to a locked-out user. Future
1704/// commits may wrap the two writes in a single transaction
1705/// when [`invalidate_sessions`] gains a transaction-aware
1706/// variant.
1707///
1708/// **Doctrine 22.** This function inserts a new row (additive)
1709/// and delegates revocation to `invalidate_sessions`. No direct
1710/// `revoked_at` write. The grep proof remains intact.
1711///
1712/// **Doctrine 17.** Trust transitions rotate the token —
1713/// always. A `Copy`-trust upgrade in place (UPDATE the same
1714/// row's `trust_level`) would let a network-captured pre-MFA
1715/// token ride into the elevated state; the rotation forbids
1716/// that.
1717#[allow(dead_code)] // call site lands at the verify POST handler in a later commit
1718pub async fn promote_session_to_mfa_verified(
1719    db: &Db,
1720    current_session_id: i64,
1721    user_id: i64,
1722) -> Result<String> {
1723    let token = random_token();
1724    let token_hash = hash_token_for_storage(&token);
1725    let expires = Utc::now() + ChronoDuration::days(MFA_VERIFIED_SESSION_DAYS);
1726
1727    // 1. Mint the new mfa_verified row with parent_session_id
1728    //    set. `token` (legacy) + `token_hash` both populated
1729    //    to match `auth::sessions::create_session` shape.
1730    sqlx::query(
1731        "INSERT INTO rustio_sessions \
1732         (token, token_hash, user_id, expires_at, trust_level, parent_session_id) \
1733         VALUES ($1, $2, $3, $4, 'mfa_verified', $5)",
1734    )
1735    .bind(&token)
1736    .bind(&token_hash)
1737    .bind(user_id)
1738    .bind(expires)
1739    .bind(current_session_id)
1740    .execute(db.pool())
1741    .await?;
1742
1743    // 2. Revoke the parent via the centralised single writer.
1744    invalidate_sessions(
1745        db,
1746        SessionTarget::Single {
1747            session_id: current_session_id,
1748        },
1749        SessionInvalidationReason::TrustEscalation,
1750    )
1751    .await?;
1752
1753    Ok(token)
1754}
1755
1756// internal:
1757/// Variant of [`crate::auth::recovery_admin::promote_session_elevated`]
1758/// for the MFA-enrolled re-auth path. UPDATEs `elevated_until +
1759/// trust_level = 'mfa_verified'` in place (no token rotation).
1760///
1761/// The R2 `promote_session_elevated` unconditionally sets
1762/// `trust_level = 'elevated'`; calling it on a session that
1763/// was already `mfa_verified` (e.g. after the login-flow
1764/// verify step in commit #12 promoted it) would DOWNGRADE the
1765/// trust level. R3's re-auth path needs a sibling that
1766/// preserves / promotes-to `mfa_verified` instead.
1767///
1768/// **In-place UPDATE rationale (Doctrine 17 trade-off).** The
1769/// re-auth wall verifies BOTH factors before this UPDATE runs
1770/// — a cookie thief without the password (and TOTP, when
1771/// enrolled) cannot land here. Per DESIGN_R3_MFA.md §12.2,
1772/// re-auth is allowed to UPDATE `trust_level` in place rather
1773/// than rotate the token, because the user has already proved
1774/// both factors live in the current request. Full trust
1775/// escalation via token rotation lives in
1776/// [`promote_session_to_mfa_verified`] for the login-flow
1777/// verify path; the re-auth path stamps the same trust level
1778/// via UPDATE without a new cookie.
1779#[allow(dead_code)] // call site lands at /admin/reauth POST in R3 commit #17
1780pub(crate) async fn promote_session_mfa_elevated(
1781    db: &Db,
1782    session_id: i64,
1783    ttl: ChronoDuration,
1784) -> Result<()> {
1785    sqlx::query(
1786        "UPDATE rustio_sessions \
1787            SET elevated_until = NOW() + (INTERVAL '1 second' * $2::bigint), \
1788                trust_level = 'mfa_verified' \
1789          WHERE session_id = $1 AND revoked_at IS NULL",
1790    )
1791    .bind(session_id)
1792    .bind(ttl.num_seconds())
1793    .execute(db.pool())
1794    .await?;
1795    Ok(())
1796}
1797
1798// public:
1799/// Framework-wide MFA enforcement policy.
1800///
1801/// Plain `Copy` enum (no trait object) — operators wire it onto
1802/// `Admin` via [`crate::admin::types::Admin::require_mfa`]. The
1803/// `login_guard` consults the active policy AFTER successful
1804/// password verification and AFTER R2's `must_change_password`
1805/// check (commit #15 of the R3 plan).
1806///
1807/// **Forward-only enforcement (D6).** Switching to
1808/// [`MfaPolicy::Required`] does NOT retroactively revoke
1809/// existing sessions. Existing users without MFA enrolled are
1810/// redirected to `/admin/mfa/enroll` at the next request that
1811/// hits `login_guard`. The pattern mirrors R2's
1812/// `must_change_password` interstitial.
1813///
1814/// **Default is [`MfaPolicy::Optional`].** R1 page copy contains
1815/// zero MFA mention; the doctrine-9 floor in DESIGN_RECOVERY
1816/// (email is convenience, not root of trust) sets the baseline.
1817/// Operators who want MFA enforcement opt in explicitly.
1818///
1819/// Typical project wiring:
1820///
1821/// ```ignore
1822/// use rustio_admin::auth::{MfaPolicy, Role};
1823///
1824/// // Enforce for everyone:
1825/// let admin = Admin::new().require_mfa(MfaPolicy::Required);
1826///
1827/// // Enforce for privileged roles only:
1828/// const PRIVILEGED: &[Role] = &[Role::Administrator, Role::Supervisor];
1829/// let admin = Admin::new().require_mfa(MfaPolicy::RequiredForRoles(PRIVILEGED));
1830///
1831/// // Reject MFA enrolment outright (e.g. for a public-kiosk admin):
1832/// let admin = Admin::new().require_mfa(MfaPolicy::Disabled);
1833/// ```
1834#[derive(Debug, Clone, Copy)]
1835pub enum MfaPolicy {
1836    /// MFA enrolment is rejected outright. Existing enrolments
1837    /// remain readable on the `rustio_users` row but the verify
1838    /// flow refuses to honour them. Used by deployments that
1839    /// have decided MFA is operationally inappropriate (kiosks,
1840    /// shared-credential workflows, etc.).
1841    Disabled,
1842    /// Default. Users may enrol; users without MFA can sign in
1843    /// with password alone. The pre-R3 framework behaviour.
1844    Optional,
1845    /// Every user must enrol. Forward-only — existing sessions
1846    /// remain valid; the `login_guard` redirects users without
1847    /// MFA to `/admin/mfa/enroll` at the next request.
1848    Required,
1849    /// Required only for users whose [`Role`] appears in the
1850    /// slice. Forward-only with the same semantics as
1851    /// [`MfaPolicy::Required`]. Empty slice is equivalent to
1852    /// [`MfaPolicy::Optional`] — the policy reads "no role
1853    /// requires MFA" rather than "no users require MFA".
1854    RequiredForRoles(&'static [Role]),
1855}
1856
1857impl Default for MfaPolicy {
1858    /// [`MfaPolicy::Optional`] is the framework default. R1 page
1859    /// copy contains zero MFA mention; operators opt into
1860    /// enforcement explicitly via
1861    /// [`crate::admin::types::Admin::require_mfa`].
1862    fn default() -> Self {
1863        Self::Optional
1864    }
1865}
1866
1867// internal:
1868/// Add the additive R3 MFA schema.
1869///
1870/// Adds four columns on `rustio_users`:
1871///
1872/// - `mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE` — the boolean
1873///   gate the login flow consults after password verification.
1874///   `FALSE` means MFA is not enrolled; the rest of the columns
1875///   are NULL. `TRUE` means the rest of the columns are
1876///   populated and `/admin/mfa/verify` is required to promote
1877///   the session to `mfa_verified`.
1878/// - `mfa_secret_ciphertext BYTEA` (nullable) — the AES-256-GCM
1879///   encrypted TOTP secret. Storage layout is
1880///   `nonce (12 bytes) || ciphertext || auth_tag (16 bytes)`.
1881///   Plaintext secret never reaches disk; decryption happens in
1882///   process memory during verification, scoped to the request
1883///   handler.
1884/// - `mfa_secret_key_id INT` (nullable) — which version of
1885///   `RUSTIO_SECRET_KEY` encrypted this row. Per-row stamp lets
1886///   key rotation proceed in stages: existing rows continue to
1887///   decrypt against their stamped key while new rows encrypt
1888///   against the active key. The retire-old-key sweep is a
1889///   future operational procedure (see §7 / Appendix E of the
1890///   design doc).
1891/// - `mfa_last_used_step BIGINT` (nullable) — the highest TOTP
1892///   step value previously accepted by `verify_totp`. Replay
1893///   protection (D4): a TOTP code from a step `≤
1894///   mfa_last_used_step` is rejected even if cryptographically
1895///   valid. Monotonic per user; never decrements.
1896///
1897/// Adds one new table for backup codes:
1898///
1899/// - `rustio_mfa_backup_codes` with `id BIGSERIAL`,
1900///   `user_id BIGINT NOT NULL REFERENCES rustio_users(id) ON
1901///   DELETE CASCADE`, `code_hash TEXT NOT NULL` (Argon2id,
1902///   low-memory params), `created_at TIMESTAMPTZ NOT NULL`,
1903///   `used_at TIMESTAMPTZ` (nullable; NULL = unused). The
1904///   `ON DELETE CASCADE` is the disable / account-deletion
1905///   contract — when the parent user disables MFA, the runtime
1906///   issues an explicit `DELETE` on these rows; when the user
1907///   row itself is deleted, cascade cleans up.
1908///
1909/// Plus a per-user partial index
1910/// `rustio_mfa_backup_codes_user_unused_idx ON (user_id) WHERE
1911/// used_at IS NULL` for the verification-path scan: at most 8
1912/// rows per user × the partial predicate makes the consume
1913/// scan an index seek to a tiny page.
1914///
1915/// **Backfill.** Existing `rustio_users` rows get the column
1916/// defaults: `mfa_enabled = FALSE`, all three NULL fields. No
1917/// pre-existing user is auto-enrolled. The new
1918/// `rustio_mfa_backup_codes` table is empty after the
1919/// migration.
1920///
1921/// **Rollback.** Rolling back to 0.6.0 (R2) is data-safe — the
1922/// columns and table become unreferenced; nothing hard-fails.
1923/// Forward migration is the supported direction; reverse is an
1924/// operator's snapshot-restore concern.
1925///
1926/// Idempotent. Safe to call on every boot. Depends on
1927/// `rustio_users` existing first (which `auth::init_tables`
1928/// guarantees by ordering this call after `init_user_tables`
1929/// and the R1 / R2 schema migrations).
1930pub(crate) async fn migrate_user_mfa_schema(db: &Db) -> Result<()> {
1931    sqlx::query(
1932        "ALTER TABLE rustio_users \
1933         ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE",
1934    )
1935    .execute(db.pool())
1936    .await?;
1937
1938    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS mfa_secret_ciphertext BYTEA")
1939        .execute(db.pool())
1940        .await?;
1941
1942    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS mfa_secret_key_id INT")
1943        .execute(db.pool())
1944        .await?;
1945
1946    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS mfa_last_used_step BIGINT")
1947        .execute(db.pool())
1948        .await?;
1949
1950    sqlx::query(
1951        "CREATE TABLE IF NOT EXISTS rustio_mfa_backup_codes ( \
1952            id          BIGSERIAL PRIMARY KEY, \
1953            user_id     BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE, \
1954            code_hash   TEXT NOT NULL, \
1955            created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
1956            used_at     TIMESTAMPTZ \
1957         )",
1958    )
1959    .execute(db.pool())
1960    .await?;
1961
1962    sqlx::query(
1963        "CREATE INDEX IF NOT EXISTS rustio_mfa_backup_codes_user_unused_idx \
1964         ON rustio_mfa_backup_codes (user_id) \
1965         WHERE used_at IS NULL",
1966    )
1967    .execute(db.pool())
1968    .await?;
1969
1970    Ok(())
1971}
1972
1973#[cfg(test)]
1974mod tests {
1975    use super::*;
1976
1977    #[test]
1978    fn default_is_optional() {
1979        assert!(matches!(MfaPolicy::default(), MfaPolicy::Optional));
1980    }
1981
1982    #[test]
1983    fn policy_is_copy() {
1984        // Copy ensures the policy can be carried by value without
1985        // Arc indirection. The compiler enforces this at the
1986        // declaration site (`#[derive(Copy)]`); this test pins
1987        // the contract so a future field addition that breaks
1988        // Copy fails the suite, not just the next caller.
1989        const ROLES: &[Role] = &[Role::Administrator];
1990        let original = MfaPolicy::RequiredForRoles(ROLES);
1991        let copy = original;
1992        // Both bindings are usable — Copy.
1993        assert!(matches!(original, MfaPolicy::RequiredForRoles(_)));
1994        assert!(matches!(copy, MfaPolicy::RequiredForRoles(_)));
1995    }
1996
1997    fn fixed_test_key() -> MfaKey {
1998        // Deterministic 32-byte key for round-trip tests. The
1999        // value is arbitrary — we just need a stable key across
2000        // wrap and unwrap calls.
2001        let mut bytes = [0u8; 32];
2002        for (i, b) in bytes.iter_mut().enumerate() {
2003            *b = (i as u8).wrapping_mul(7).wrapping_add(13);
2004        }
2005        MfaKey::from_bytes(bytes)
2006    }
2007
2008    #[test]
2009    fn wrap_unwrap_round_trip_recovers_plaintext() {
2010        let key = fixed_test_key();
2011        let plaintext = b"hello-mfa-secret-20-bytes";
2012        let ciphertext = wrap_secret(plaintext, &key);
2013
2014        // Storage layout: nonce (12) || ciphertext || tag (16).
2015        // Plaintext is 25 bytes ⇒ ciphertext_with_tag is 41 ⇒
2016        // total 53.
2017        assert_eq!(ciphertext.len(), 12 + plaintext.len() + 16);
2018
2019        let recovered = unwrap_secret(&ciphertext, &key).expect("round-trip must decrypt");
2020        assert_eq!(recovered, plaintext);
2021    }
2022
2023    #[test]
2024    fn wrap_uses_fresh_nonce_per_call() {
2025        // Two encryptions of the same plaintext under the same
2026        // key must NOT collide — fresh nonce per call. Without
2027        // this the AEAD's confidentiality breaks (same nonce +
2028        // same key + different plaintexts leaks XOR via known-
2029        // plaintext attacks).
2030        let key = fixed_test_key();
2031        let plaintext = b"identical-plaintext";
2032        let a = wrap_secret(plaintext, &key);
2033        let b = wrap_secret(plaintext, &key);
2034        assert_ne!(a, b, "fresh nonce per call must yield different ciphertext");
2035    }
2036
2037    #[test]
2038    fn tampered_ciphertext_fails_aead_verification() {
2039        let key = fixed_test_key();
2040        let plaintext = b"sensitive-mfa-secret";
2041        let mut ciphertext = wrap_secret(plaintext, &key);
2042
2043        // Flip a bit in the ciphertext body (post-nonce, pre-tag).
2044        ciphertext[20] ^= 0x01;
2045        let result = unwrap_secret(&ciphertext, &key);
2046        assert!(
2047            result.is_err(),
2048            "tampered ciphertext must fail AEAD verification"
2049        );
2050    }
2051
2052    #[test]
2053    fn wrong_key_fails_decryption() {
2054        let key_enc = fixed_test_key();
2055        let key_dec = MfaKey::from_bytes([0xFFu8; 32]);
2056        let plaintext = b"wrong-key-test";
2057        let ciphertext = wrap_secret(plaintext, &key_enc);
2058
2059        let result = unwrap_secret(&ciphertext, &key_dec);
2060        assert!(result.is_err(), "decrypt with wrong key must fail");
2061    }
2062
2063    #[test]
2064    fn truncated_input_rejects_explicitly() {
2065        let key = fixed_test_key();
2066        // 27 bytes — one byte short of nonce + tag minimum.
2067        let too_short = [0u8; 27];
2068        let result = unwrap_secret(&too_short, &key);
2069        assert!(result.is_err(), "input below 28 bytes must reject");
2070    }
2071
2072    // ---- backup codes (R3 commit #4) -----------------------------
2073
2074    #[test]
2075    fn alphabet_is_31_chars_no_ambiguous() {
2076        // The 31-char alphabet excludes 0/O/1/I/L per
2077        // DESIGN_R3_MFA.md Appendix B. This test pins the
2078        // alphabet — if a future commit accidentally adds an
2079        // ambiguous character, the suite catches it.
2080        assert_eq!(BACKUP_CODE_ALPHABET.len(), 31);
2081        for &b in BACKUP_CODE_ALPHABET {
2082            let c = b as char;
2083            assert!(c.is_ascii_alphanumeric(), "non-alphanumeric: {c:?}");
2084            assert!(
2085                !matches!(c, '0' | 'O' | '1' | 'I' | 'L'),
2086                "ambiguous char in alphabet: {c:?}"
2087            );
2088        }
2089    }
2090
2091    #[test]
2092    fn generate_returns_count_codes() {
2093        let codes = generate_backup_codes(BACKUP_CODE_COUNT);
2094        assert_eq!(codes.len(), BACKUP_CODE_COUNT);
2095    }
2096
2097    #[test]
2098    fn each_code_is_xxxx_dash_xxxx_shape() {
2099        let codes = generate_backup_codes(8);
2100        for code in &codes {
2101            assert_eq!(code.len(), BACKUP_CODE_LEN + 1, "wrong length: {code:?}");
2102            assert_eq!(
2103                code.chars().nth(4),
2104                Some('-'),
2105                "hyphen missing at position 4: {code:?}"
2106            );
2107            // Every non-hyphen char is in the locked alphabet.
2108            for (i, c) in code.chars().enumerate() {
2109                if i == 4 {
2110                    continue;
2111                }
2112                assert!(
2113                    BACKUP_CODE_ALPHABET.contains(&(c as u8)),
2114                    "char {c:?} at position {i} not in alphabet"
2115                );
2116            }
2117        }
2118    }
2119
2120    #[test]
2121    fn generated_codes_are_unique_within_batch() {
2122        // Birthday-bound for 8 codes from a 31^8 ≈ 9 × 10^11
2123        // space is well below the collision threshold. A repeated
2124        // code in a single batch would indicate the RNG is broken
2125        // or the alphabet is much smaller than expected.
2126        let codes = generate_backup_codes(64);
2127        let unique: std::collections::HashSet<_> = codes.iter().cloned().collect();
2128        assert_eq!(unique.len(), 64, "batch contained duplicates");
2129    }
2130
2131    #[test]
2132    fn normalise_strips_hyphens_and_uppercases() {
2133        assert_eq!(normalise_backup_code("ABCD-EFGH"), "ABCDEFGH");
2134        assert_eq!(normalise_backup_code("abcd-efgh"), "ABCDEFGH");
2135        assert_eq!(normalise_backup_code("AbCdEfGh"), "ABCDEFGH");
2136        assert_eq!(normalise_backup_code(" abcd efgh "), "ABCDEFGH");
2137        assert_eq!(normalise_backup_code("abcdefgh"), "ABCDEFGH");
2138    }
2139
2140    #[test]
2141    fn normalise_is_idempotent() {
2142        let once = normalise_backup_code("xxxx-yyyy");
2143        let twice = normalise_backup_code(&once);
2144        assert_eq!(once, twice);
2145    }
2146
2147    #[test]
2148    fn hash_verify_round_trip() {
2149        let code = "ABCDEFGH";
2150        let hash = hash_backup_code(code).expect("hashing must succeed");
2151        assert!(verify_backup_code(code, &hash), "round-trip must verify");
2152    }
2153
2154    #[test]
2155    fn hash_uses_argon2id_low_memory_params() {
2156        // Argon2's PHC string carries the params; this test pins
2157        // them so a future "let's tune Argon2" change either
2158        // updates the locked-decision table OR fails the suite
2159        // here.
2160        let hash = hash_backup_code("ABCDEFGH").expect("hash succeeds");
2161        assert!(hash.starts_with("$argon2id$"), "wrong algorithm: {hash}");
2162        assert!(
2163            hash.contains("m=16384,t=2,p=1"),
2164            "params drifted from locked m=16MB/t=2/p=1: {hash}"
2165        );
2166    }
2167
2168    #[test]
2169    fn verify_rejects_wrong_code() {
2170        let hash = hash_backup_code("ABCDEFGH").expect("hash succeeds");
2171        assert!(!verify_backup_code("WRONGCDE", &hash));
2172    }
2173
2174    #[test]
2175    fn verify_rejects_invalid_phc_string() {
2176        // Garbage hash must not panic — must return false.
2177        assert!(!verify_backup_code("ABCDEFGH", "not-a-phc-hash"));
2178        assert!(!verify_backup_code("ABCDEFGH", ""));
2179    }
2180
2181    #[test]
2182    fn separate_hash_calls_yield_different_phc_strings() {
2183        // Fresh salt per call ⇒ same plaintext hashes differently.
2184        // Without this, an attacker who learns one hash trivially
2185        // recognises whether two users share the same code.
2186        let a = hash_backup_code("ABCDEFGH").expect("a");
2187        let b = hash_backup_code("ABCDEFGH").expect("b");
2188        assert_ne!(a, b, "fresh salt must produce different hashes");
2189        // But both still verify the original code.
2190        assert!(verify_backup_code("ABCDEFGH", &a));
2191        assert!(verify_backup_code("ABCDEFGH", &b));
2192    }
2193
2194    // ---- TOTP RFC 6238 (R3 commit #5) ----------------------------
2195
2196    /// RFC 6238 Appendix B test secret (20 ASCII bytes).
2197    const RFC6238_SECRET: &[u8] = b"12345678901234567890";
2198
2199    #[test]
2200    fn current_step_at_canonical_30s_interval() {
2201        // T=0 → step 0; T=29 → step 0; T=30 → step 1; T=59 → step 1;
2202        // T=60 → step 2.
2203        assert_eq!(current_step(0, 30), 0);
2204        assert_eq!(current_step(29, 30), 0);
2205        assert_eq!(current_step(30, 30), 1);
2206        assert_eq!(current_step(59, 30), 1);
2207        assert_eq!(current_step(60, 30), 2);
2208    }
2209
2210    #[test]
2211    fn rfc6238_appendix_b_test_vectors_truncated_to_6_digits() {
2212        // The RFC 6238 Appendix B vectors are 8-digit codes.
2213        // Authenticator apps render 6 digits by default, so the
2214        // framework's generate_totp returns the 6-digit form
2215        // (the last 6 digits of the 8-digit RFC value, since
2216        // truncation is `bin_code % 10^digits`).
2217        //
2218        // Source: RFC 6238 Appendix B, "TOTP Algorithm: Test
2219        // Vectors", SHA-1 column.
2220        //
2221        //  T (sec)        | 8-digit (RFC)  | 6-digit (this fn)
2222        //  ---------------+----------------+------------------
2223        //  59             | 94287082       | 287082
2224        //  1111111109     | 07081804       |  81804
2225        //  1111111111     | 14050471       |  50471
2226        //  1234567890     | 89005924       |   5924
2227        //  2000000000     | 69279037       | 279037
2228        //  20000000000    | 65353130       | 353130
2229        let cases: &[(u64, u32)] = &[
2230            (59, 287_082),
2231            (1_111_111_109, 81_804),
2232            (1_111_111_111, 50_471),
2233            (1_234_567_890, 5_924),
2234            (2_000_000_000, 279_037),
2235            (20_000_000_000, 353_130),
2236        ];
2237
2238        for &(t, expected) in cases {
2239            let step = current_step(t, 30);
2240            let got = generate_totp(RFC6238_SECRET, step);
2241            assert_eq!(got, expected, "RFC 6238 vector at T={t} mismatched");
2242        }
2243    }
2244
2245    #[test]
2246    fn generate_totp_returns_six_digit_range() {
2247        // Across a sample of steps, the result must fit in
2248        // [0, 999_999] — the modulo guarantees this but a future
2249        // refactor could lose the modulo silently.
2250        for step in [0u64, 1, 100, 12_345, u64::MAX] {
2251            let code = generate_totp(RFC6238_SECRET, step);
2252            assert!(
2253                code < 1_000_000,
2254                "code out of range for step {step}: {code}"
2255            );
2256        }
2257    }
2258
2259    #[test]
2260    fn verify_accepts_current_step() {
2261        let t = 1_111_111_111u64;
2262        let step = current_step(t, 30);
2263        let code = generate_totp(RFC6238_SECRET, step);
2264        assert_eq!(verify_totp(RFC6238_SECRET, code, t, 30, 1), Some(step));
2265    }
2266
2267    #[test]
2268    fn verify_accepts_one_step_skew() {
2269        // Generate at step S, verify at step S+1's wall-clock
2270        // (T += step_seconds). With skew=1, the previous step
2271        // is still accepted.
2272        let t_gen = 1_111_111_111u64;
2273        let step_gen = current_step(t_gen, 30);
2274        let code = generate_totp(RFC6238_SECRET, step_gen);
2275
2276        let t_verify = t_gen + 30; // one step later
2277        let result = verify_totp(RFC6238_SECRET, code, t_verify, 30, 1);
2278        assert_eq!(result, Some(step_gen), "skew ±1 must accept previous step");
2279    }
2280
2281    #[test]
2282    fn verify_rejects_two_step_skew_when_window_is_one() {
2283        // Generate at step S, verify at step S+2's wall-clock
2284        // with skew=1. Falls outside the [S+1, S+3] acceptance
2285        // window seen from T=S+2.
2286        let t_gen = 1_111_111_111u64;
2287        let step_gen = current_step(t_gen, 30);
2288        let code = generate_totp(RFC6238_SECRET, step_gen);
2289
2290        let t_verify = t_gen + 60; // two steps later
2291        let result = verify_totp(RFC6238_SECRET, code, t_verify, 30, 1);
2292        assert_eq!(result, None, "skew=1 must reject two-step drift");
2293    }
2294
2295    #[test]
2296    fn totp_verify_rejects_wrong_code() {
2297        let t = 1_111_111_111u64;
2298        let result = verify_totp(RFC6238_SECRET, 999_999, t, 30, 1);
2299        assert_eq!(result, None);
2300    }
2301
2302    #[test]
2303    fn verify_does_not_underflow_at_t_zero() {
2304        // Skew window at T=0 would mathematically include step
2305        // -1; the saturating_add().max(0) guard maps it to step
2306        // 0, so the verify just retries step 0 instead of
2307        // panicking on integer underflow.
2308        let code = generate_totp(RFC6238_SECRET, 0);
2309        let result = verify_totp(RFC6238_SECRET, code, 0, 30, 1);
2310        assert_eq!(result, Some(0));
2311    }
2312
2313    // ---- enrolment runtime (R3 commit #6) ------------------------
2314
2315    #[test]
2316    fn base32_rfc4648_test_vector_foobar() {
2317        // RFC 4648 §10 standard test vector. Pins the encoder
2318        // against the canonical reference; if the alphabet or
2319        // bit-packing drifts, this test fails.
2320        assert_eq!(base32_encode_no_pad(b"foobar"), "MZXW6YTBOI");
2321    }
2322
2323    #[test]
2324    fn base32_rfc4648_progressive_test_vectors() {
2325        // Additional RFC 4648 §10 vectors covering 1, 2, 3, 4,
2326        // 5 input bytes — exercises every path through the
2327        // bit-packing loop's leftover-bits flush.
2328        // (Outputs are the no-padding form; standard test
2329        // vectors include `=` padding which we strip per the
2330        // otpauth:// URL convention.)
2331        assert_eq!(base32_encode_no_pad(b"f"), "MY");
2332        assert_eq!(base32_encode_no_pad(b"fo"), "MZXQ");
2333        assert_eq!(base32_encode_no_pad(b"foo"), "MZXW6");
2334        assert_eq!(base32_encode_no_pad(b"foob"), "MZXW6YQ");
2335        assert_eq!(base32_encode_no_pad(b"fooba"), "MZXW6YTB");
2336    }
2337
2338    #[test]
2339    fn provision_secret_returns_20_bytes() {
2340        let secret = provision_secret();
2341        assert_eq!(
2342            secret.secret_bytes.len(),
2343            20,
2344            "RFC 6238 default + universal authenticator-app interop"
2345        );
2346    }
2347
2348    #[test]
2349    fn provision_secret_base32_length_matches_secret() {
2350        // 20 bytes × 8 bits = 160 bits / 5 bits per base32 char
2351        // = 32 chars exactly (no padding needed).
2352        let secret = provision_secret();
2353        assert_eq!(secret.base32.len(), 32);
2354        // Every char is in the base32 alphabet.
2355        for c in secret.base32.chars() {
2356            assert!(
2357                c.is_ascii_uppercase() || ('2'..='7').contains(&c),
2358                "non-base32 char in encoding: {c:?}"
2359            );
2360        }
2361    }
2362
2363    #[test]
2364    fn provision_secret_each_call_yields_different_secret() {
2365        // Birthday-bound for 20-byte secrets is astronomical;
2366        // a collision in 16 calls indicates the RNG is broken.
2367        let mut seen = std::collections::HashSet::new();
2368        for _ in 0..16 {
2369            let secret = provision_secret();
2370            assert!(seen.insert(secret.secret_bytes), "RNG produced duplicate");
2371        }
2372    }
2373
2374    // ---- enrolment URL + base32 decode (R3 commit #13) ----------
2375
2376    #[test]
2377    fn build_otpauth_url_matches_google_authenticator_format() {
2378        // Standard otpauth:// URI per Google Authenticator's
2379        // Key URI Format spec. Issuer + account must appear
2380        // both in the path AND in the &issuer= query param;
2381        // the algorithm / digits / period are explicit so
2382        // apps that don't read defaults still get the right
2383        // values.
2384        let url = build_otpauth_url("Acme Corp", "alice@example.com", "MZXW6YTBOI", 30);
2385        assert!(
2386            url.starts_with("otpauth://totp/Acme%20Corp:alice%40example.com?"),
2387            "wrong path encoding: {url}"
2388        );
2389        assert!(url.contains("secret=MZXW6YTBOI"), "secret missing: {url}");
2390        assert!(
2391            url.contains("issuer=Acme%20Corp"),
2392            "issuer query missing: {url}"
2393        );
2394        assert!(url.contains("algorithm=SHA1"), "algorithm missing: {url}");
2395        assert!(url.contains("digits=6"), "digits missing: {url}");
2396        assert!(url.contains("period=30"), "period missing: {url}");
2397    }
2398
2399    #[test]
2400    fn base32_decode_rfc4648_round_trips_progressive_vectors() {
2401        // Inverse of the base32_rfc4648_progressive_test_vectors
2402        // test from commit #6. The pair of tests pins the
2403        // encoder + decoder against each other AND against the
2404        // RFC 4648 spec.
2405        let cases: &[(&str, &[u8])] = &[
2406            ("MY", b"f"),
2407            ("MZXQ", b"fo"),
2408            ("MZXW6", b"foo"),
2409            ("MZXW6YQ", b"foob"),
2410            ("MZXW6YTB", b"fooba"),
2411            ("MZXW6YTBOI", b"foobar"),
2412        ];
2413        for &(encoded, expected) in cases {
2414            let decoded =
2415                base32_decode_no_pad(encoded).unwrap_or_else(|| panic!("decode failed: {encoded}"));
2416            assert_eq!(decoded.as_slice(), expected, "round-trip {encoded}");
2417        }
2418    }
2419
2420    #[test]
2421    fn base32_decode_tolerates_hyphens_spaces_padding_and_lowercase() {
2422        // Users paste secrets in different shapes; the decoder
2423        // collapses them all to the canonical bytes.
2424        for variant in [
2425            "MZXW6YTBOI",
2426            "mzxw6ytboi",
2427            "MZXW 6YTB OI",
2428            "MZXW-6YTB-OI",
2429            "MZXW6YTBOI==",
2430        ] {
2431            assert_eq!(
2432                base32_decode_no_pad(variant).expect("decode should succeed"),
2433                b"foobar",
2434                "variant: {variant:?}"
2435            );
2436        }
2437    }
2438
2439    #[test]
2440    fn base32_decode_rejects_non_alphabet_chars() {
2441        // The four ambiguity-rejected base32 letters (0, 1,
2442        // 8, 9) and other non-alphabet chars must return None
2443        // — not silently coerce. The handler maps None to a
2444        // uniform invalid-code response.
2445        assert!(base32_decode_no_pad("ABC0DEF").is_none());
2446        assert!(base32_decode_no_pad("ABC1DEF").is_none());
2447        assert!(base32_decode_no_pad("ABC8DEF").is_none());
2448        assert!(base32_decode_no_pad("ABC9DEF").is_none());
2449        assert!(base32_decode_no_pad("hello!").is_none());
2450    }
2451}