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