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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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(crate) 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         {} new codes issued",
1661        BACKUP_CODE_COUNT
1662    );
1663    audit_record(db, entry).await?;
1664
1665    Ok(RegenOutcome::Regenerated {
1666        plain_backup_codes: plain_codes,
1667        previous_codes_invalidated: previous_count,
1668    })
1669}
1670
1671// -----------------------------------------------------------------
1672// Trust-escalation primitive (R3 commit #11)
1673// -----------------------------------------------------------------
1674
1675// internal:
1676/// Promote a session from `authenticated` to `mfa_verified` via
1677/// token rotation per `DESIGN_SESSIONS.md` §11 + Doctrine 17.
1678///
1679/// Called by the verify POST handler after either
1680/// [`verify_totp_for_user`] or [`consume_backup_code`] returns
1681/// success. The function:
1682///
1683/// 1. Mints a fresh session row with:
1684///    - new random `token` + `token_hash`,
1685///    - `trust_level = 'mfa_verified'`,
1686///    - `parent_session_id = current_session_id` (the row that
1687///      was just MFA-verified — establishes the audit lineage),
1688///    - `user_id` unchanged,
1689///    - `expires_at = NOW() + 14 days`.
1690/// 2. Revokes the parent row via
1691///    `auth::sessions::invalidate_sessions` with
1692///    `SessionInvalidationReason::TrustEscalation`. Doctrine 22:
1693///    no direct `revoked_at` write here; the centralised
1694///    invalidator owns that.
1695/// 3. Returns the new plaintext token. The caller (handler)
1696///    sets it as the framework's session cookie, replacing the
1697///    pre-MFA token.
1698///
1699/// **Ordering rationale.** The new row is INSERTed before the
1700/// parent is revoked. A crash between the two operations leaves
1701/// the user with two active session rows (old + new) rather
1702/// than zero — the more recoverable failure mode. The next
1703/// request authenticates against either row; an over-permissive
1704/// transient state is preferable to a locked-out user. Future
1705/// commits may wrap the two writes in a single transaction
1706/// when [`invalidate_sessions`] gains a transaction-aware
1707/// variant.
1708///
1709/// **Doctrine 22.** This function inserts a new row (additive)
1710/// and delegates revocation to `invalidate_sessions`. No direct
1711/// `revoked_at` write. The grep proof remains intact.
1712///
1713/// **Doctrine 17.** Trust transitions rotate the token —
1714/// always. A `Copy`-trust upgrade in place (UPDATE the same
1715/// row's `trust_level`) would let a network-captured pre-MFA
1716/// token ride into the elevated state; the rotation forbids
1717/// that.
1718#[allow(dead_code)] // call site lands at the verify POST handler in a later commit
1719pub(crate) async fn promote_session_to_mfa_verified(
1720    db: &Db,
1721    current_session_id: i64,
1722    user_id: i64,
1723) -> Result<String> {
1724    let token = random_token();
1725    let token_hash = hash_token_for_storage(&token);
1726    let expires = Utc::now() + ChronoDuration::days(MFA_VERIFIED_SESSION_DAYS);
1727
1728    // 1. Mint the new mfa_verified row with parent_session_id
1729    //    set. `token` (legacy) + `token_hash` both populated
1730    //    to match `auth::sessions::create_session` shape.
1731    sqlx::query(
1732        "INSERT INTO rustio_sessions \
1733         (token, token_hash, user_id, expires_at, trust_level, parent_session_id) \
1734         VALUES ($1, $2, $3, $4, 'mfa_verified', $5)",
1735    )
1736    .bind(&token)
1737    .bind(&token_hash)
1738    .bind(user_id)
1739    .bind(expires)
1740    .bind(current_session_id)
1741    .execute(db.pool())
1742    .await?;
1743
1744    // 2. Revoke the parent via the centralised single writer.
1745    invalidate_sessions(
1746        db,
1747        SessionTarget::Single {
1748            session_id: current_session_id,
1749        },
1750        SessionInvalidationReason::TrustEscalation,
1751    )
1752    .await?;
1753
1754    Ok(token)
1755}
1756
1757// internal:
1758/// Variant of [`crate::auth::recovery_admin::promote_session_elevated`]
1759/// for the MFA-enrolled re-auth path. UPDATEs `elevated_until +
1760/// trust_level = 'mfa_verified'` in place (no token rotation).
1761///
1762/// The R2 `promote_session_elevated` unconditionally sets
1763/// `trust_level = 'elevated'`; calling it on a session that
1764/// was already `mfa_verified` (e.g. after the login-flow
1765/// verify step in commit #12 promoted it) would DOWNGRADE the
1766/// trust level. R3's re-auth path needs a sibling that
1767/// preserves / promotes-to `mfa_verified` instead.
1768///
1769/// **In-place UPDATE rationale (Doctrine 17 trade-off).** The
1770/// re-auth wall verifies BOTH factors before this UPDATE runs
1771/// — a cookie thief without the password (and TOTP, when
1772/// enrolled) cannot land here. Per DESIGN_R3_MFA.md §12.2,
1773/// re-auth is allowed to UPDATE `trust_level` in place rather
1774/// than rotate the token, because the user has already proved
1775/// both factors live in the current request. Full trust
1776/// escalation via token rotation lives in
1777/// [`promote_session_to_mfa_verified`] for the login-flow
1778/// verify path; the re-auth path stamps the same trust level
1779/// via UPDATE without a new cookie.
1780#[allow(dead_code)] // call site lands at /admin/reauth POST in R3 commit #17
1781pub(crate) async fn promote_session_mfa_elevated(
1782    db: &Db,
1783    session_id: i64,
1784    ttl: ChronoDuration,
1785) -> Result<()> {
1786    sqlx::query(
1787        "UPDATE rustio_sessions \
1788            SET elevated_until = NOW() + (INTERVAL '1 second' * $2::bigint), \
1789                trust_level = 'mfa_verified' \
1790          WHERE session_id = $1 AND revoked_at IS NULL",
1791    )
1792    .bind(session_id)
1793    .bind(ttl.num_seconds())
1794    .execute(db.pool())
1795    .await?;
1796    Ok(())
1797}
1798
1799// public:
1800/// Framework-wide MFA enforcement policy.
1801///
1802/// Plain `Copy` enum (no trait object) — operators wire it onto
1803/// `Admin` via [`crate::admin::types::Admin::require_mfa`]. The
1804/// `login_guard` consults the active policy AFTER successful
1805/// password verification and AFTER R2's `must_change_password`
1806/// check (commit #15 of the R3 plan).
1807///
1808/// **Forward-only enforcement (D6).** Switching to
1809/// [`MfaPolicy::Required`] does NOT retroactively revoke
1810/// existing sessions. Existing users without MFA enrolled are
1811/// redirected to `/admin/mfa/enroll` at the next request that
1812/// hits `login_guard`. The pattern mirrors R2's
1813/// `must_change_password` interstitial.
1814///
1815/// **Default is [`MfaPolicy::Optional`].** R1 page copy contains
1816/// zero MFA mention; the doctrine-9 floor in DESIGN_RECOVERY
1817/// (email is convenience, not root of trust) sets the baseline.
1818/// Operators who want MFA enforcement opt in explicitly.
1819///
1820/// Typical project wiring:
1821///
1822/// ```ignore
1823/// use rustio_admin::auth::{MfaPolicy, Role};
1824///
1825/// // Enforce for everyone:
1826/// let admin = Admin::new().require_mfa(MfaPolicy::Required);
1827///
1828/// // Enforce for privileged roles only:
1829/// const PRIVILEGED: &[Role] = &[Role::Administrator, Role::Supervisor];
1830/// let admin = Admin::new().require_mfa(MfaPolicy::RequiredForRoles(PRIVILEGED));
1831///
1832/// // Reject MFA enrolment outright (e.g. for a public-kiosk admin):
1833/// let admin = Admin::new().require_mfa(MfaPolicy::Disabled);
1834/// ```
1835#[derive(Debug, Clone, Copy)]
1836pub enum MfaPolicy {
1837    /// MFA enrolment is rejected outright. Existing enrolments
1838    /// remain readable on the `rustio_users` row but the verify
1839    /// flow refuses to honour them. Used by deployments that
1840    /// have decided MFA is operationally inappropriate (kiosks,
1841    /// shared-credential workflows, etc.).
1842    Disabled,
1843    /// Default. Users may enrol; users without MFA can sign in
1844    /// with password alone. The pre-R3 framework behaviour.
1845    Optional,
1846    /// Every user must enrol. Forward-only — existing sessions
1847    /// remain valid; the `login_guard` redirects users without
1848    /// MFA to `/admin/mfa/enroll` at the next request.
1849    Required,
1850    /// Required only for users whose [`Role`] appears in the
1851    /// slice. Forward-only with the same semantics as
1852    /// [`MfaPolicy::Required`]. Empty slice is equivalent to
1853    /// [`MfaPolicy::Optional`] — the policy reads "no role
1854    /// requires MFA" rather than "no users require MFA".
1855    RequiredForRoles(&'static [Role]),
1856}
1857
1858impl Default for MfaPolicy {
1859    /// [`MfaPolicy::Optional`] is the framework default. R1 page
1860    /// copy contains zero MFA mention; operators opt into
1861    /// enforcement explicitly via
1862    /// [`crate::admin::types::Admin::require_mfa`].
1863    fn default() -> Self {
1864        Self::Optional
1865    }
1866}
1867
1868// internal:
1869/// Add the additive R3 MFA schema.
1870///
1871/// Adds four columns on `rustio_users`:
1872///
1873/// - `mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE` — the boolean
1874///   gate the login flow consults after password verification.
1875///   `FALSE` means MFA is not enrolled; the rest of the columns
1876///   are NULL. `TRUE` means the rest of the columns are
1877///   populated and `/admin/mfa/verify` is required to promote
1878///   the session to `mfa_verified`.
1879/// - `mfa_secret_ciphertext BYTEA` (nullable) — the AES-256-GCM
1880///   encrypted TOTP secret. Storage layout is
1881///   `nonce (12 bytes) || ciphertext || auth_tag (16 bytes)`.
1882///   Plaintext secret never reaches disk; decryption happens in
1883///   process memory during verification, scoped to the request
1884///   handler.
1885/// - `mfa_secret_key_id INT` (nullable) — which version of
1886///   `RUSTIO_SECRET_KEY` encrypted this row. Per-row stamp lets
1887///   key rotation proceed in stages: existing rows continue to
1888///   decrypt against their stamped key while new rows encrypt
1889///   against the active key. The retire-old-key sweep is a
1890///   future operational procedure (see §7 / Appendix E of the
1891///   design doc).
1892/// - `mfa_last_used_step BIGINT` (nullable) — the highest TOTP
1893///   step value previously accepted by `verify_totp`. Replay
1894///   protection (D4): a TOTP code from a step `≤
1895///   mfa_last_used_step` is rejected even if cryptographically
1896///   valid. Monotonic per user; never decrements.
1897///
1898/// Adds one new table for backup codes:
1899///
1900/// - `rustio_mfa_backup_codes` with `id BIGSERIAL`,
1901///   `user_id BIGINT NOT NULL REFERENCES rustio_users(id) ON
1902///   DELETE CASCADE`, `code_hash TEXT NOT NULL` (Argon2id,
1903///   low-memory params), `created_at TIMESTAMPTZ NOT NULL`,
1904///   `used_at TIMESTAMPTZ` (nullable; NULL = unused). The
1905///   `ON DELETE CASCADE` is the disable / account-deletion
1906///   contract — when the parent user disables MFA, the runtime
1907///   issues an explicit `DELETE` on these rows; when the user
1908///   row itself is deleted, cascade cleans up.
1909///
1910/// Plus a per-user partial index
1911/// `rustio_mfa_backup_codes_user_unused_idx ON (user_id) WHERE
1912/// used_at IS NULL` for the verification-path scan: at most 8
1913/// rows per user × the partial predicate makes the consume
1914/// scan an index seek to a tiny page.
1915///
1916/// **Backfill.** Existing `rustio_users` rows get the column
1917/// defaults: `mfa_enabled = FALSE`, all three NULL fields. No
1918/// pre-existing user is auto-enrolled. The new
1919/// `rustio_mfa_backup_codes` table is empty after the
1920/// migration.
1921///
1922/// **Rollback.** Rolling back to 0.6.0 (R2) is data-safe — the
1923/// columns and table become unreferenced; nothing hard-fails.
1924/// Forward migration is the supported direction; reverse is an
1925/// operator's snapshot-restore concern.
1926///
1927/// Idempotent. Safe to call on every boot. Depends on
1928/// `rustio_users` existing first (which `auth::init_tables`
1929/// guarantees by ordering this call after `init_user_tables`
1930/// and the R1 / R2 schema migrations).
1931pub(crate) async fn migrate_user_mfa_schema(db: &Db) -> Result<()> {
1932    sqlx::query(
1933        "ALTER TABLE rustio_users \
1934         ADD COLUMN IF NOT EXISTS mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE",
1935    )
1936    .execute(db.pool())
1937    .await?;
1938
1939    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS mfa_secret_ciphertext BYTEA")
1940        .execute(db.pool())
1941        .await?;
1942
1943    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS mfa_secret_key_id INT")
1944        .execute(db.pool())
1945        .await?;
1946
1947    sqlx::query("ALTER TABLE rustio_users ADD COLUMN IF NOT EXISTS mfa_last_used_step BIGINT")
1948        .execute(db.pool())
1949        .await?;
1950
1951    sqlx::query(
1952        "CREATE TABLE IF NOT EXISTS rustio_mfa_backup_codes ( \
1953            id          BIGSERIAL PRIMARY KEY, \
1954            user_id     BIGINT NOT NULL REFERENCES rustio_users(id) ON DELETE CASCADE, \
1955            code_hash   TEXT NOT NULL, \
1956            created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(), \
1957            used_at     TIMESTAMPTZ \
1958         )",
1959    )
1960    .execute(db.pool())
1961    .await?;
1962
1963    sqlx::query(
1964        "CREATE INDEX IF NOT EXISTS rustio_mfa_backup_codes_user_unused_idx \
1965         ON rustio_mfa_backup_codes (user_id) \
1966         WHERE used_at IS NULL",
1967    )
1968    .execute(db.pool())
1969    .await?;
1970
1971    Ok(())
1972}
1973
1974#[cfg(test)]
1975mod tests {
1976    use super::*;
1977
1978    #[test]
1979    fn default_is_optional() {
1980        assert!(matches!(MfaPolicy::default(), MfaPolicy::Optional));
1981    }
1982
1983    #[test]
1984    fn policy_is_copy() {
1985        // Copy ensures the policy can be carried by value without
1986        // Arc indirection. The compiler enforces this at the
1987        // declaration site (`#[derive(Copy)]`); this test pins
1988        // the contract so a future field addition that breaks
1989        // Copy fails the suite, not just the next caller.
1990        const ROLES: &[Role] = &[Role::Administrator];
1991        let original = MfaPolicy::RequiredForRoles(ROLES);
1992        let copy = original;
1993        // Both bindings are usable — Copy.
1994        assert!(matches!(original, MfaPolicy::RequiredForRoles(_)));
1995        assert!(matches!(copy, MfaPolicy::RequiredForRoles(_)));
1996    }
1997
1998    fn fixed_test_key() -> MfaKey {
1999        // Deterministic 32-byte key for round-trip tests. The
2000        // value is arbitrary — we just need a stable key across
2001        // wrap and unwrap calls.
2002        let mut bytes = [0u8; 32];
2003        for (i, b) in bytes.iter_mut().enumerate() {
2004            *b = (i as u8).wrapping_mul(7).wrapping_add(13);
2005        }
2006        MfaKey::from_bytes(bytes)
2007    }
2008
2009    #[test]
2010    fn wrap_unwrap_round_trip_recovers_plaintext() {
2011        let key = fixed_test_key();
2012        let plaintext = b"hello-mfa-secret-20-bytes";
2013        let ciphertext = wrap_secret(plaintext, &key);
2014
2015        // Storage layout: nonce (12) || ciphertext || tag (16).
2016        // Plaintext is 25 bytes ⇒ ciphertext_with_tag is 41 ⇒
2017        // total 53.
2018        assert_eq!(ciphertext.len(), 12 + plaintext.len() + 16);
2019
2020        let recovered = unwrap_secret(&ciphertext, &key).expect("round-trip must decrypt");
2021        assert_eq!(recovered, plaintext);
2022    }
2023
2024    #[test]
2025    fn wrap_uses_fresh_nonce_per_call() {
2026        // Two encryptions of the same plaintext under the same
2027        // key must NOT collide — fresh nonce per call. Without
2028        // this the AEAD's confidentiality breaks (same nonce +
2029        // same key + different plaintexts leaks XOR via known-
2030        // plaintext attacks).
2031        let key = fixed_test_key();
2032        let plaintext = b"identical-plaintext";
2033        let a = wrap_secret(plaintext, &key);
2034        let b = wrap_secret(plaintext, &key);
2035        assert_ne!(a, b, "fresh nonce per call must yield different ciphertext");
2036    }
2037
2038    #[test]
2039    fn tampered_ciphertext_fails_aead_verification() {
2040        let key = fixed_test_key();
2041        let plaintext = b"sensitive-mfa-secret";
2042        let mut ciphertext = wrap_secret(plaintext, &key);
2043
2044        // Flip a bit in the ciphertext body (post-nonce, pre-tag).
2045        ciphertext[20] ^= 0x01;
2046        let result = unwrap_secret(&ciphertext, &key);
2047        assert!(
2048            result.is_err(),
2049            "tampered ciphertext must fail AEAD verification"
2050        );
2051    }
2052
2053    #[test]
2054    fn wrong_key_fails_decryption() {
2055        let key_enc = fixed_test_key();
2056        let key_dec = MfaKey::from_bytes([0xFFu8; 32]);
2057        let plaintext = b"wrong-key-test";
2058        let ciphertext = wrap_secret(plaintext, &key_enc);
2059
2060        let result = unwrap_secret(&ciphertext, &key_dec);
2061        assert!(result.is_err(), "decrypt with wrong key must fail");
2062    }
2063
2064    #[test]
2065    fn truncated_input_rejects_explicitly() {
2066        let key = fixed_test_key();
2067        // 27 bytes — one byte short of nonce + tag minimum.
2068        let too_short = [0u8; 27];
2069        let result = unwrap_secret(&too_short, &key);
2070        assert!(result.is_err(), "input below 28 bytes must reject");
2071    }
2072
2073    // ---- backup codes (R3 commit #4) -----------------------------
2074
2075    #[test]
2076    fn alphabet_is_31_chars_no_ambiguous() {
2077        // The 31-char alphabet excludes 0/O/1/I/L per
2078        // DESIGN_R3_MFA.md Appendix B. This test pins the
2079        // alphabet — if a future commit accidentally adds an
2080        // ambiguous character, the suite catches it.
2081        assert_eq!(BACKUP_CODE_ALPHABET.len(), 31);
2082        for &b in BACKUP_CODE_ALPHABET {
2083            let c = b as char;
2084            assert!(c.is_ascii_alphanumeric(), "non-alphanumeric: {c:?}");
2085            assert!(
2086                !matches!(c, '0' | 'O' | '1' | 'I' | 'L'),
2087                "ambiguous char in alphabet: {c:?}"
2088            );
2089        }
2090    }
2091
2092    #[test]
2093    fn generate_returns_count_codes() {
2094        let codes = generate_backup_codes(BACKUP_CODE_COUNT);
2095        assert_eq!(codes.len(), BACKUP_CODE_COUNT);
2096    }
2097
2098    #[test]
2099    fn each_code_is_xxxx_dash_xxxx_shape() {
2100        let codes = generate_backup_codes(8);
2101        for code in &codes {
2102            assert_eq!(code.len(), BACKUP_CODE_LEN + 1, "wrong length: {code:?}");
2103            assert_eq!(
2104                code.chars().nth(4),
2105                Some('-'),
2106                "hyphen missing at position 4: {code:?}"
2107            );
2108            // Every non-hyphen char is in the locked alphabet.
2109            for (i, c) in code.chars().enumerate() {
2110                if i == 4 {
2111                    continue;
2112                }
2113                assert!(
2114                    BACKUP_CODE_ALPHABET.contains(&(c as u8)),
2115                    "char {c:?} at position {i} not in alphabet"
2116                );
2117            }
2118        }
2119    }
2120
2121    #[test]
2122    fn generated_codes_are_unique_within_batch() {
2123        // Birthday-bound for 8 codes from a 31^8 ≈ 9 × 10^11
2124        // space is well below the collision threshold. A repeated
2125        // code in a single batch would indicate the RNG is broken
2126        // or the alphabet is much smaller than expected.
2127        let codes = generate_backup_codes(64);
2128        let unique: std::collections::HashSet<_> = codes.iter().cloned().collect();
2129        assert_eq!(unique.len(), 64, "batch contained duplicates");
2130    }
2131
2132    #[test]
2133    fn normalise_strips_hyphens_and_uppercases() {
2134        assert_eq!(normalise_backup_code("ABCD-EFGH"), "ABCDEFGH");
2135        assert_eq!(normalise_backup_code("abcd-efgh"), "ABCDEFGH");
2136        assert_eq!(normalise_backup_code("AbCdEfGh"), "ABCDEFGH");
2137        assert_eq!(normalise_backup_code(" abcd efgh "), "ABCDEFGH");
2138        assert_eq!(normalise_backup_code("abcdefgh"), "ABCDEFGH");
2139    }
2140
2141    #[test]
2142    fn normalise_is_idempotent() {
2143        let once = normalise_backup_code("xxxx-yyyy");
2144        let twice = normalise_backup_code(&once);
2145        assert_eq!(once, twice);
2146    }
2147
2148    #[test]
2149    fn hash_verify_round_trip() {
2150        let code = "ABCDEFGH";
2151        let hash = hash_backup_code(code).expect("hashing must succeed");
2152        assert!(verify_backup_code(code, &hash), "round-trip must verify");
2153    }
2154
2155    #[test]
2156    fn hash_uses_argon2id_low_memory_params() {
2157        // Argon2's PHC string carries the params; this test pins
2158        // them so a future "let's tune Argon2" change either
2159        // updates the locked-decision table OR fails the suite
2160        // here.
2161        let hash = hash_backup_code("ABCDEFGH").expect("hash succeeds");
2162        assert!(hash.starts_with("$argon2id$"), "wrong algorithm: {hash}");
2163        assert!(
2164            hash.contains("m=16384,t=2,p=1"),
2165            "params drifted from locked m=16MB/t=2/p=1: {hash}"
2166        );
2167    }
2168
2169    #[test]
2170    fn verify_rejects_wrong_code() {
2171        let hash = hash_backup_code("ABCDEFGH").expect("hash succeeds");
2172        assert!(!verify_backup_code("WRONGCDE", &hash));
2173    }
2174
2175    #[test]
2176    fn verify_rejects_invalid_phc_string() {
2177        // Garbage hash must not panic — must return false.
2178        assert!(!verify_backup_code("ABCDEFGH", "not-a-phc-hash"));
2179        assert!(!verify_backup_code("ABCDEFGH", ""));
2180    }
2181
2182    #[test]
2183    fn separate_hash_calls_yield_different_phc_strings() {
2184        // Fresh salt per call ⇒ same plaintext hashes differently.
2185        // Without this, an attacker who learns one hash trivially
2186        // recognises whether two users share the same code.
2187        let a = hash_backup_code("ABCDEFGH").expect("a");
2188        let b = hash_backup_code("ABCDEFGH").expect("b");
2189        assert_ne!(a, b, "fresh salt must produce different hashes");
2190        // But both still verify the original code.
2191        assert!(verify_backup_code("ABCDEFGH", &a));
2192        assert!(verify_backup_code("ABCDEFGH", &b));
2193    }
2194
2195    // ---- TOTP RFC 6238 (R3 commit #5) ----------------------------
2196
2197    /// RFC 6238 Appendix B test secret (20 ASCII bytes).
2198    const RFC6238_SECRET: &[u8] = b"12345678901234567890";
2199
2200    #[test]
2201    fn current_step_at_canonical_30s_interval() {
2202        // T=0 → step 0; T=29 → step 0; T=30 → step 1; T=59 → step 1;
2203        // T=60 → step 2.
2204        assert_eq!(current_step(0, 30), 0);
2205        assert_eq!(current_step(29, 30), 0);
2206        assert_eq!(current_step(30, 30), 1);
2207        assert_eq!(current_step(59, 30), 1);
2208        assert_eq!(current_step(60, 30), 2);
2209    }
2210
2211    #[test]
2212    fn rfc6238_appendix_b_test_vectors_truncated_to_6_digits() {
2213        // The RFC 6238 Appendix B vectors are 8-digit codes.
2214        // Authenticator apps render 6 digits by default, so the
2215        // framework's generate_totp returns the 6-digit form
2216        // (the last 6 digits of the 8-digit RFC value, since
2217        // truncation is `bin_code % 10^digits`).
2218        //
2219        // Source: RFC 6238 Appendix B, "TOTP Algorithm: Test
2220        // Vectors", SHA-1 column.
2221        //
2222        //  T (sec)        | 8-digit (RFC)  | 6-digit (this fn)
2223        //  ---------------+----------------+------------------
2224        //  59             | 94287082       | 287082
2225        //  1111111109     | 07081804       |  81804
2226        //  1111111111     | 14050471       |  50471
2227        //  1234567890     | 89005924       |   5924
2228        //  2000000000     | 69279037       | 279037
2229        //  20000000000    | 65353130       | 353130
2230        let cases: &[(u64, u32)] = &[
2231            (59, 287_082),
2232            (1_111_111_109, 81_804),
2233            (1_111_111_111, 50_471),
2234            (1_234_567_890, 5_924),
2235            (2_000_000_000, 279_037),
2236            (20_000_000_000, 353_130),
2237        ];
2238
2239        for &(t, expected) in cases {
2240            let step = current_step(t, 30);
2241            let got = generate_totp(RFC6238_SECRET, step);
2242            assert_eq!(got, expected, "RFC 6238 vector at T={t} mismatched");
2243        }
2244    }
2245
2246    #[test]
2247    fn generate_totp_returns_six_digit_range() {
2248        // Across a sample of steps, the result must fit in
2249        // [0, 999_999] — the modulo guarantees this but a future
2250        // refactor could lose the modulo silently.
2251        for step in [0u64, 1, 100, 12_345, u64::MAX] {
2252            let code = generate_totp(RFC6238_SECRET, step);
2253            assert!(
2254                code < 1_000_000,
2255                "code out of range for step {step}: {code}"
2256            );
2257        }
2258    }
2259
2260    #[test]
2261    fn verify_accepts_current_step() {
2262        let t = 1_111_111_111u64;
2263        let step = current_step(t, 30);
2264        let code = generate_totp(RFC6238_SECRET, step);
2265        assert_eq!(verify_totp(RFC6238_SECRET, code, t, 30, 1), Some(step));
2266    }
2267
2268    #[test]
2269    fn verify_accepts_one_step_skew() {
2270        // Generate at step S, verify at step S+1's wall-clock
2271        // (T += step_seconds). With skew=1, the previous step
2272        // is still accepted.
2273        let t_gen = 1_111_111_111u64;
2274        let step_gen = current_step(t_gen, 30);
2275        let code = generate_totp(RFC6238_SECRET, step_gen);
2276
2277        let t_verify = t_gen + 30; // one step later
2278        let result = verify_totp(RFC6238_SECRET, code, t_verify, 30, 1);
2279        assert_eq!(result, Some(step_gen), "skew ±1 must accept previous step");
2280    }
2281
2282    #[test]
2283    fn verify_rejects_two_step_skew_when_window_is_one() {
2284        // Generate at step S, verify at step S+2's wall-clock
2285        // with skew=1. Falls outside the [S+1, S+3] acceptance
2286        // window seen from T=S+2.
2287        let t_gen = 1_111_111_111u64;
2288        let step_gen = current_step(t_gen, 30);
2289        let code = generate_totp(RFC6238_SECRET, step_gen);
2290
2291        let t_verify = t_gen + 60; // two steps later
2292        let result = verify_totp(RFC6238_SECRET, code, t_verify, 30, 1);
2293        assert_eq!(result, None, "skew=1 must reject two-step drift");
2294    }
2295
2296    #[test]
2297    fn totp_verify_rejects_wrong_code() {
2298        let t = 1_111_111_111u64;
2299        let result = verify_totp(RFC6238_SECRET, 999_999, t, 30, 1);
2300        assert_eq!(result, None);
2301    }
2302
2303    #[test]
2304    fn verify_does_not_underflow_at_t_zero() {
2305        // Skew window at T=0 would mathematically include step
2306        // -1; the saturating_add().max(0) guard maps it to step
2307        // 0, so the verify just retries step 0 instead of
2308        // panicking on integer underflow.
2309        let code = generate_totp(RFC6238_SECRET, 0);
2310        let result = verify_totp(RFC6238_SECRET, code, 0, 30, 1);
2311        assert_eq!(result, Some(0));
2312    }
2313
2314    // ---- enrolment runtime (R3 commit #6) ------------------------
2315
2316    #[test]
2317    fn base32_rfc4648_test_vector_foobar() {
2318        // RFC 4648 §10 standard test vector. Pins the encoder
2319        // against the canonical reference; if the alphabet or
2320        // bit-packing drifts, this test fails.
2321        assert_eq!(base32_encode_no_pad(b"foobar"), "MZXW6YTBOI");
2322    }
2323
2324    #[test]
2325    fn base32_rfc4648_progressive_test_vectors() {
2326        // Additional RFC 4648 §10 vectors covering 1, 2, 3, 4,
2327        // 5 input bytes — exercises every path through the
2328        // bit-packing loop's leftover-bits flush.
2329        // (Outputs are the no-padding form; standard test
2330        // vectors include `=` padding which we strip per the
2331        // otpauth:// URL convention.)
2332        assert_eq!(base32_encode_no_pad(b"f"), "MY");
2333        assert_eq!(base32_encode_no_pad(b"fo"), "MZXQ");
2334        assert_eq!(base32_encode_no_pad(b"foo"), "MZXW6");
2335        assert_eq!(base32_encode_no_pad(b"foob"), "MZXW6YQ");
2336        assert_eq!(base32_encode_no_pad(b"fooba"), "MZXW6YTB");
2337    }
2338
2339    #[test]
2340    fn provision_secret_returns_20_bytes() {
2341        let secret = provision_secret();
2342        assert_eq!(
2343            secret.secret_bytes.len(),
2344            20,
2345            "RFC 6238 default + universal authenticator-app interop"
2346        );
2347    }
2348
2349    #[test]
2350    fn provision_secret_base32_length_matches_secret() {
2351        // 20 bytes × 8 bits = 160 bits / 5 bits per base32 char
2352        // = 32 chars exactly (no padding needed).
2353        let secret = provision_secret();
2354        assert_eq!(secret.base32.len(), 32);
2355        // Every char is in the base32 alphabet.
2356        for c in secret.base32.chars() {
2357            assert!(
2358                c.is_ascii_uppercase() || ('2'..='7').contains(&c),
2359                "non-base32 char in encoding: {c:?}"
2360            );
2361        }
2362    }
2363
2364    #[test]
2365    fn provision_secret_each_call_yields_different_secret() {
2366        // Birthday-bound for 20-byte secrets is astronomical;
2367        // a collision in 16 calls indicates the RNG is broken.
2368        let mut seen = std::collections::HashSet::new();
2369        for _ in 0..16 {
2370            let secret = provision_secret();
2371            assert!(seen.insert(secret.secret_bytes), "RNG produced duplicate");
2372        }
2373    }
2374
2375    // ---- enrolment URL + base32 decode (R3 commit #13) ----------
2376
2377    #[test]
2378    fn build_otpauth_url_matches_google_authenticator_format() {
2379        // Standard otpauth:// URI per Google Authenticator's
2380        // Key URI Format spec. Issuer + account must appear
2381        // both in the path AND in the &issuer= query param;
2382        // the algorithm / digits / period are explicit so
2383        // apps that don't read defaults still get the right
2384        // values.
2385        let url = build_otpauth_url("Acme Corp", "alice@example.com", "MZXW6YTBOI", 30);
2386        assert!(
2387            url.starts_with("otpauth://totp/Acme%20Corp:alice%40example.com?"),
2388            "wrong path encoding: {url}"
2389        );
2390        assert!(url.contains("secret=MZXW6YTBOI"), "secret missing: {url}");
2391        assert!(
2392            url.contains("issuer=Acme%20Corp"),
2393            "issuer query missing: {url}"
2394        );
2395        assert!(url.contains("algorithm=SHA1"), "algorithm missing: {url}");
2396        assert!(url.contains("digits=6"), "digits missing: {url}");
2397        assert!(url.contains("period=30"), "period missing: {url}");
2398    }
2399
2400    #[test]
2401    fn base32_decode_rfc4648_round_trips_progressive_vectors() {
2402        // Inverse of the base32_rfc4648_progressive_test_vectors
2403        // test from commit #6. The pair of tests pins the
2404        // encoder + decoder against each other AND against the
2405        // RFC 4648 spec.
2406        let cases: &[(&str, &[u8])] = &[
2407            ("MY", b"f"),
2408            ("MZXQ", b"fo"),
2409            ("MZXW6", b"foo"),
2410            ("MZXW6YQ", b"foob"),
2411            ("MZXW6YTB", b"fooba"),
2412            ("MZXW6YTBOI", b"foobar"),
2413        ];
2414        for &(encoded, expected) in cases {
2415            let decoded =
2416                base32_decode_no_pad(encoded).unwrap_or_else(|| panic!("decode failed: {encoded}"));
2417            assert_eq!(decoded.as_slice(), expected, "round-trip {encoded}");
2418        }
2419    }
2420
2421    #[test]
2422    fn base32_decode_tolerates_hyphens_spaces_padding_and_lowercase() {
2423        // Users paste secrets in different shapes; the decoder
2424        // collapses them all to the canonical bytes.
2425        for variant in [
2426            "MZXW6YTBOI",
2427            "mzxw6ytboi",
2428            "MZXW 6YTB OI",
2429            "MZXW-6YTB-OI",
2430            "MZXW6YTBOI==",
2431        ] {
2432            assert_eq!(
2433                base32_decode_no_pad(variant).expect("decode should succeed"),
2434                b"foobar",
2435                "variant: {variant:?}"
2436            );
2437        }
2438    }
2439
2440    #[test]
2441    fn base32_decode_rejects_non_alphabet_chars() {
2442        // The four ambiguity-rejected base32 letters (0, 1,
2443        // 8, 9) and other non-alphabet chars must return None
2444        // — not silently coerce. The handler maps None to a
2445        // uniform invalid-code response.
2446        assert!(base32_decode_no_pad("ABC0DEF").is_none());
2447        assert!(base32_decode_no_pad("ABC1DEF").is_none());
2448        assert!(base32_decode_no_pad("ABC8DEF").is_none());
2449        assert!(base32_decode_no_pad("ABC9DEF").is_none());
2450        assert!(base32_decode_no_pad("hello!").is_none());
2451    }
2452}