Skip to main content

#
jellytau_lib/profiles/
pin.rs

    1//! Profile PIN: hashing, and the lockout policy that decides what a guess costs.
    2//!
    3//! The policy half is deliberately pure — it takes the stored counter state and
    4//! the current time, and returns the decision plus the next state. That is what
    5//! makes "five wrong guesses then a lockout that survives a restart" testable
    6//! without a database, a clock, or a running app.
    7//!
    8//! What this is *not*: at-rest protection. The PIN gates switching to a profile;
    9//! it does not encrypt that profile's access token, so anyone holding the
    10//! database and the keyring has every token regardless. That trade is deliberate
    11//! and its reasoning lives in DR-268 — a wrapped token would leave a locked
    12//! profile unable to resume its own downloads or drain its own sync queue until
    13//! somebody walked past and typed the code.
    14//!
    15//! TRACES: UR-083 | DR-268
    16
    17use argon2::Argon2;
    18use chrono::{DateTime, Duration, Utc};
    19use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
    20
    21/// Wrong guesses allowed before the first lockout.
    22pub const MAX_ATTEMPTS: u32 = 5;
    23
    24/// How long the first lockout lasts. Each subsequent failure doubles it.
    25const BASE_LOCKOUT_SECS: i64 = 60;
    26
    27/// Ceiling on the doubling, so a forgotten PIN never bricks the tile — the
    28/// password route is always there, and a lockout measured in hours would push
    29/// people towards not setting a PIN at all.
    30const MAX_LOCKOUT_SECS: i64 = 15 * 60;
    31
    32/// Persisted counter state for one profile's PIN.
    33///
    34/// TRACES: UR-083 | DR-268
    35#[derive(Debug, Clone, PartialEq, Eq)]
    36pub struct PinState {
    37    pub failed_count: u32,
    38    pub locked_until: Option<DateTime<Utc>>,
    39}
    40
    41impl PinState {
    42    pub fn fresh() -> Self {
    43        Self {
    44            failed_count: 0,
    45            locked_until: None,
    46        }
    47    }
    48}
    49
    50/// What the caller should do with an attempt.
    51#[derive(Debug, Clone, PartialEq, Eq)]
    52pub enum PinDecision {
    53    Accept,
    54    Reject { attempts_remaining: u32 },
    55    Locked { until: DateTime<Utc> },
    56}
    57
    58/// Decide an attempt and produce the state to persist.
    59///
    60/// `pin_matches` is the result of the hash comparison; passing it in rather than
    61/// doing the comparison here is what keeps this function pure and cheap to test
    62/// across the whole attempt/lockout space.
    63///
    64/// A locked profile is refused *without consulting the hash*, so a caller cannot
    65/// burn through a lockout by guessing quickly.
    66///
    67/// TRACES: UR-083 | DR-268
    68pub fn evaluate(
    69    state: &PinState,
    70    now: DateTime<Utc>,
    71    pin_matches: bool,
    72) -> (PinDecision, PinState) {
    73    if let Some(until) = state.locked_until {
    74        if now < until {
    75            return (PinDecision::Locked { until }, state.clone());
    76        }
    77    }
    78
    79    if pin_matches {
    80        return (PinDecision::Accept, PinState::fresh());
    81    }
    82
    83    let failed_count = state.failed_count.saturating_add(1);
    84
    85    if failed_count >= MAX_ATTEMPTS {
    86        let over = i64::from(failed_count - MAX_ATTEMPTS);
    87        let secs = BASE_LOCKOUT_SECS
    88            .saturating_mul(1i64.checked_shl(over.min(16) as u32).unwrap_or(i64::MAX))
    89            .min(MAX_LOCKOUT_SECS);
    90        let until = now + Duration::seconds(secs);
    91        (
    92            PinDecision::Locked { until },
    93            PinState {
    94                failed_count,
    95                locked_until: Some(until),
    96            },
    97        )
    98    } else {
    99        (
    100            PinDecision::Reject {
    101                attempts_remaining: MAX_ATTEMPTS - failed_count,
    102            },
    103            PinState {
    104                failed_count,
    105                locked_until: None,
    106            },
    107        )
    108    }
    109}
    110
    111/// A PIN must be 4–8 digits. Rejecting non-digits here rather than in the pad
    112/// keeps the rule where the rule is enforced.
    113///
    114/// TRACES: UR-083 | DR-268
    115pub fn validate_pin(pin: &str) -> Result<(), String> {
    116    if pin.len() < 4 || pin.len() > 8 {
    117        return Err("PIN must be between 4 and 8 digits".to_string());
    118    }
    119    if !pin.chars().all(|c| c.is_ascii_digit()) {
    120        return Err("PIN must contain only digits".to_string());
    121    }
    122    Ok(())
    123}
    124
    125/// Hash a PIN for storage. Returns a PHC string with the salt embedded.
    126///
    127/// TRACES: UR-083 | DR-268
    128pub fn hash_pin(pin: &str) -> Result<String, String> {
    129    let salt = SaltString::generate(&mut OsRng);
    130    Argon2::default()
    131        .hash_password(pin.as_bytes(), &salt)
    132        .map(|h| h.to_string())
    133        .map_err(|e| format!("Failed to hash PIN: {}", e))
    134}
    135
    136/// Compare a candidate PIN against a stored PHC string.
    137///
    138/// A malformed stored hash verifies as `false` rather than erroring: a corrupt
    139/// row should send the user down the password route, not wedge the picker.
    140///
    141/// TRACES: UR-083 | DR-268
    142pub fn verify_pin(pin: &str, stored: &str) -> bool {
    143    match PasswordHash::new(stored) {
    144        Ok(parsed) => Argon2::default()
    145            .verify_password(pin.as_bytes(), &parsed)
    146            .is_ok(),
    147        Err(e) => {
    148            log::warn!("[Profiles] Stored PIN hash is unreadable: {}", e);
    149            false
    150        }
    151    }
    152}
    153
    154#[cfg(test)]
    155mod tests {
    156    use super::*;
    157
    158    fn t0() -> DateTime<Utc> {
    159        DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
    160            .unwrap()
    161            .with_timezone(&Utc)
    162    }
    163
    164    /// UT: a correct PIN is accepted and clears any accumulated failures.
    165    #[test]
    166    fn correct_pin_accepts_and_resets() {
    167        let state = PinState {
    168            failed_count: 3,
    169            locked_until: None,
    170        };
    171        let (decision, next) = evaluate(&state, t0(), true);
    172        assert_eq!(decision, PinDecision::Accept);
    173        assert_eq!(next, PinState::fresh());
    174    }
    175
    176    /// UT: wrong guesses count down, and the count is what gets persisted —
    177    /// this is the half that must survive an app restart.
    178    #[test]
    179    fn wrong_pin_counts_down() {
    180        let mut state = PinState::fresh();
    181        for expected in (1..MAX_ATTEMPTS).rev() {
    182            let (decision, next) = evaluate(&state, t0(), false);
    183            assert_eq!(
    184                decision,
    185                PinDecision::Reject {
    186                    attempts_remaining: expected
    187                }
    188            );
    189            state = next;
    190        }
    191        assert_eq!(state.failed_count, MAX_ATTEMPTS - 1);
    192    }
    193
    194    /// UT: the attempt that exhausts the allowance locks out rather than
    195    /// reporting zero attempts remaining.
    196    #[test]
    197    fn exhausting_attempts_locks_out() {
    198        let state = PinState {
    199            failed_count: MAX_ATTEMPTS - 1,
    200            locked_until: None,
    201        };
    202        let (decision, next) = evaluate(&state, t0(), false);
    203        match decision {
    204            PinDecision::Locked { until } => {
    205                assert_eq!(until, t0() + Duration::seconds(BASE_LOCKOUT_SECS));
    206            }
    207            other => panic!("expected lockout, got {:?}", other),
    208        }
    209        assert_eq!(next.locked_until, Some(t0() + Duration::seconds(60)));
    210    }
    211
    212    /// UT: a locked profile is refused without the hash being consulted — the
    213    /// correct PIN does not shortcut an active lockout.
    214    #[test]
    215    fn lockout_refuses_even_a_correct_pin() {
    216        let until = t0() + Duration::seconds(60);
    217        let state = PinState {
    218            failed_count: MAX_ATTEMPTS,
    219            locked_until: Some(until),
    220        };
    221        let (decision, next) = evaluate(&state, t0(), true);
    222        assert_eq!(decision, PinDecision::Locked { until });
    223        assert_eq!(next, state, "a refused attempt must not extend the lockout");
    224    }
    225
    226    /// UT: once the window passes the profile accepts again.
    227    #[test]
    228    fn lockout_expires() {
    229        let until = t0() + Duration::seconds(60);
    230        let state = PinState {
    231            failed_count: MAX_ATTEMPTS,
    232            locked_until: Some(until),
    233        };
    234        let (decision, next) = evaluate(&state, until + Duration::seconds(1), true);
    235        assert_eq!(decision, PinDecision::Accept);
    236        assert_eq!(next, PinState::fresh());
    237    }
    238
    239    /// UT: repeated lockouts escalate, but stop at the ceiling so a forgotten
    240    /// PIN never becomes an hours-long wait.
    241    #[test]
    242    fn lockout_escalates_to_a_ceiling() {
    243        let mut seen = Vec::new();
    244        for failed in MAX_ATTEMPTS - 1..MAX_ATTEMPTS + 12 {
    245            let state = PinState {
    246                failed_count: failed,
    247                locked_until: None,
    248            };
    249            if let (PinDecision::Locked { until }, _) = evaluate(&state, t0(), false) {
    250                seen.push((until - t0()).num_seconds());
    251            }
    252        }
    253        assert_eq!(seen[0], BASE_LOCKOUT_SECS);
    254        assert!(seen[1] > seen[0], "second lockout should be longer");
    255        assert_eq!(*seen.last().unwrap(), MAX_LOCKOUT_SECS);
    256        assert!(seen.windows(2).all(|w| w[1] >= w[0]), "must not shrink");
    257    }
    258
    259    /// UT: hashing round-trips, and a wrong PIN does not verify.
    260    #[test]
    261    fn hash_round_trips() {
    262        let hash = hash_pin("1234").unwrap();
    263        assert!(verify_pin("1234", &hash));
    264        assert!(!verify_pin("4321", &hash));
    265    }
    266
    267    /// UT: the stored hash never contains the PIN itself.
    268    #[test]
    269    fn hash_does_not_leak_the_pin() {
    270        let hash = hash_pin("246813").unwrap();
    271        assert!(!hash.contains("246813"));
    272    }
    273
    274    /// UT: an unreadable stored hash fails closed instead of erroring, so a
    275    /// corrupt row sends the user to the password route.
    276    #[test]
    277    fn corrupt_hash_fails_closed() {
    278        assert!(!verify_pin("1234", "not-a-phc-string"));
    279    }
    280
    281    /// UT: PIN shape is enforced in Rust, not in the pad.
    282    #[test]
    283    fn pin_shape_is_validated() {
    284        assert!(validate_pin("1234").is_ok());
    285        assert!(validate_pin("12345678").is_ok());
    286        assert!(validate_pin("123").is_err(), "too short");
    287        assert!(validate_pin("123456789").is_err(), "too long");
    288        assert!(validate_pin("12a4").is_err(), "non-digit");
    289    }
    290}