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}