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