1use argon2::Argon2;
18use chrono::{DateTime, Duration, Utc};
19use password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
20
21pub const MAX_ATTEMPTS: u32 = 5;
23
24const BASE_LOCKOUT_SECS: i64 = 60;
26
27const MAX_LOCKOUT_SECS: i64 = 15 * 60;
31
32#[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#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum PinDecision {
53 Accept,
54 Reject { attempts_remaining: u32 },
55 Locked { until: DateTime<Utc> },
56}
57
58pub 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
111pub 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
125pub 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
136pub 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
269 fn hash_does_not_leak_the_pin() {
270 let hash = hash_pin("246813").unwrap();
271 assert!(!hash.contains("246813"));
272 }
273
274 #[test]
277 fn corrupt_hash_fails_closed() {
278 assert!(!verify_pin("1234", "not-a-phc-string"));
279 }
280
281 #[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}