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