//! Profile PIN: hashing, and the lockout policy that decides what a guess costs. //! //! The policy half is deliberately pure — it takes the stored counter state and //! the current time, and returns the decision plus the next state. That is what //! makes "five wrong guesses then a lockout that survives a restart" testable //! without a database, a clock, or a running app. //! //! What this is *not*: at-rest protection. The PIN gates switching to a profile; //! it does not encrypt that profile's access token, so anyone holding the //! database and the keyring has every token regardless. That trade is deliberate //! and its reasoning lives in DR-268 — a wrapped token would leave a locked //! profile unable to resume its own downloads or drain its own sync queue until //! somebody walked past and typed the code. //! //! TRACES: UR-083 | DR-268 use argon2::Argon2; use chrono::{DateTime, Duration, Utc}; use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; /// Wrong guesses allowed before the first lockout. pub const MAX_ATTEMPTS: u32 = 5; /// How long the first lockout lasts. Each subsequent failure doubles it. const BASE_LOCKOUT_SECS: i64 = 60; /// Ceiling on the doubling, so a forgotten PIN never bricks the tile — the /// password route is always there, and a lockout measured in hours would push /// people towards not setting a PIN at all. const MAX_LOCKOUT_SECS: i64 = 15 * 60; /// Persisted counter state for one profile's PIN. /// /// TRACES: UR-083 | DR-268 #[derive(Debug, Clone, PartialEq, Eq)] pub struct PinState { pub failed_count: u32, pub locked_until: Option>, } impl PinState { pub fn fresh() -> Self { Self { failed_count: 0, locked_until: None, } } } /// What the caller should do with an attempt. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PinDecision { Accept, Reject { attempts_remaining: u32 }, Locked { until: DateTime }, } /// Decide an attempt and produce the state to persist. /// /// `pin_matches` is the result of the hash comparison; passing it in rather than /// doing the comparison here is what keeps this function pure and cheap to test /// across the whole attempt/lockout space. /// /// A locked profile is refused *without consulting the hash*, so a caller cannot /// burn through a lockout by guessing quickly. /// /// TRACES: UR-083 | DR-268 pub fn evaluate( state: &PinState, now: DateTime, pin_matches: bool, ) -> (PinDecision, PinState) { if let Some(until) = state.locked_until { if now < until { return (PinDecision::Locked { until }, state.clone()); } } if pin_matches { return (PinDecision::Accept, PinState::fresh()); } let failed_count = state.failed_count.saturating_add(1); if failed_count >= MAX_ATTEMPTS { let over = i64::from(failed_count - MAX_ATTEMPTS); let secs = BASE_LOCKOUT_SECS .saturating_mul(1i64.checked_shl(over.min(16) as u32).unwrap_or(i64::MAX)) .min(MAX_LOCKOUT_SECS); let until = now + Duration::seconds(secs); ( PinDecision::Locked { until }, PinState { failed_count, locked_until: Some(until), }, ) } else { ( PinDecision::Reject { attempts_remaining: MAX_ATTEMPTS - failed_count, }, PinState { failed_count, locked_until: None, }, ) } } /// A PIN must be 4–8 digits. Rejecting non-digits here rather than in the pad /// keeps the rule where the rule is enforced. /// /// TRACES: UR-083 | DR-268 pub fn validate_pin(pin: &str) -> Result<(), String> { if pin.len() < 4 || pin.len() > 8 { return Err("PIN must be between 4 and 8 digits".to_string()); } if !pin.chars().all(|c| c.is_ascii_digit()) { return Err("PIN must contain only digits".to_string()); } Ok(()) } /// Hash a PIN for storage. Returns a PHC string with the salt embedded. /// /// TRACES: UR-083 | DR-268 pub fn hash_pin(pin: &str) -> Result { let salt = SaltString::generate(&mut OsRng); Argon2::default() .hash_password(pin.as_bytes(), &salt) .map(|h| h.to_string()) .map_err(|e| format!("Failed to hash PIN: {}", e)) } /// Compare a candidate PIN against a stored PHC string. /// /// A malformed stored hash verifies as `false` rather than erroring: a corrupt /// row should send the user down the password route, not wedge the picker. /// /// TRACES: UR-083 | DR-268 pub fn verify_pin(pin: &str, stored: &str) -> bool { match PasswordHash::new(stored) { Ok(parsed) => Argon2::default() .verify_password(pin.as_bytes(), &parsed) .is_ok(), Err(e) => { log::warn!("[Profiles] Stored PIN hash is unreadable: {}", e); false } } } #[cfg(test)] mod tests { use super::*; fn t0() -> DateTime { DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") .unwrap() .with_timezone(&Utc) } /// UT: a correct PIN is accepted and clears any accumulated failures. #[test] fn correct_pin_accepts_and_resets() { let state = PinState { failed_count: 3, locked_until: None, }; let (decision, next) = evaluate(&state, t0(), true); assert_eq!(decision, PinDecision::Accept); assert_eq!(next, PinState::fresh()); } /// UT: wrong guesses count down, and the count is what gets persisted — /// this is the half that must survive an app restart. #[test] fn wrong_pin_counts_down() { let mut state = PinState::fresh(); for expected in (1..MAX_ATTEMPTS).rev() { let (decision, next) = evaluate(&state, t0(), false); assert_eq!( decision, PinDecision::Reject { attempts_remaining: expected } ); state = next; } assert_eq!(state.failed_count, MAX_ATTEMPTS - 1); } /// UT: the attempt that exhausts the allowance locks out rather than /// reporting zero attempts remaining. #[test] fn exhausting_attempts_locks_out() { let state = PinState { failed_count: MAX_ATTEMPTS - 1, locked_until: None, }; let (decision, next) = evaluate(&state, t0(), false); match decision { PinDecision::Locked { until } => { assert_eq!(until, t0() + Duration::seconds(BASE_LOCKOUT_SECS)); } other => panic!("expected lockout, got {:?}", other), } assert_eq!(next.locked_until, Some(t0() + Duration::seconds(60))); } /// UT: a locked profile is refused without the hash being consulted — the /// correct PIN does not shortcut an active lockout. #[test] fn lockout_refuses_even_a_correct_pin() { let until = t0() + Duration::seconds(60); let state = PinState { failed_count: MAX_ATTEMPTS, locked_until: Some(until), }; let (decision, next) = evaluate(&state, t0(), true); assert_eq!(decision, PinDecision::Locked { until }); assert_eq!(next, state, "a refused attempt must not extend the lockout"); } /// UT: once the window passes the profile accepts again. #[test] fn lockout_expires() { let until = t0() + Duration::seconds(60); let state = PinState { failed_count: MAX_ATTEMPTS, locked_until: Some(until), }; let (decision, next) = evaluate(&state, until + Duration::seconds(1), true); assert_eq!(decision, PinDecision::Accept); assert_eq!(next, PinState::fresh()); } /// UT: repeated lockouts escalate, but stop at the ceiling so a forgotten /// PIN never becomes an hours-long wait. #[test] fn lockout_escalates_to_a_ceiling() { let mut seen = Vec::new(); for failed in MAX_ATTEMPTS - 1..MAX_ATTEMPTS + 12 { let state = PinState { failed_count: failed, locked_until: None, }; if let (PinDecision::Locked { until }, _) = evaluate(&state, t0(), false) { seen.push((until - t0()).num_seconds()); } } assert_eq!(seen[0], BASE_LOCKOUT_SECS); assert!(seen[1] > seen[0], "second lockout should be longer"); assert_eq!(*seen.last().unwrap(), MAX_LOCKOUT_SECS); assert!(seen.windows(2).all(|w| w[1] >= w[0]), "must not shrink"); } /// UT: hashing round-trips, and a wrong PIN does not verify. #[test] fn hash_round_trips() { let hash = hash_pin("1234").unwrap(); assert!(verify_pin("1234", &hash)); assert!(!verify_pin("4321", &hash)); } /// UT: the stored hash never contains the PIN itself. #[test] fn hash_does_not_leak_the_pin() { let hash = hash_pin("246813").unwrap(); assert!(!hash.contains("246813")); } /// UT: an unreadable stored hash fails closed instead of erroring, so a /// corrupt row sends the user to the password route. #[test] fn corrupt_hash_fails_closed() { assert!(!verify_pin("1234", "not-a-phc-string")); } /// UT: PIN shape is enforced in Rust, not in the pad. #[test] fn pin_shape_is_validated() { assert!(validate_pin("1234").is_ok()); assert!(validate_pin("12345678").is_ok()); assert!(validate_pin("123").is_err(), "too short"); assert!(validate_pin("123456789").is_err(), "too long"); assert!(validate_pin("12a4").is_err(), "non-digit"); } }