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}