1use argon2::{
10 password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
11 Argon2, PasswordHash, PasswordVerifier,
12};
13
14pub fn hash_password(password: &str) -> String {
17 let salt = SaltString::generate(&mut OsRng);
18 Argon2::default()
19 .hash_password(password.as_bytes(), &salt)
20 .expect("argon2 hash should succeed")
21 .to_string()
22}
23
24pub fn verify_password(password: &str, hash: &str) -> bool {
27 let parsed = match PasswordHash::new(hash) {
28 Ok(h) => h,
29 Err(_) => return false,
30 };
31 Argon2::default()
32 .verify_password(password.as_bytes(), &parsed)
33 .is_ok()
34}
35
36pub fn dummy_hash() -> &'static str {
42 "$argon2id$v=19$m=19456,t=2,p=1$YWFhYWFhYWFhYWFhYWFhYQ$b3W/3pZzm6S8w5qYvJ8y3A"
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
51pub enum PasswordPolicyError {
52 TooShort { got: usize, want: usize },
54 Pwned { occurrences: u64 },
58}
59
60impl std::fmt::Display for PasswordPolicyError {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Self::TooShort { got, want } => {
64 write!(f, "password too short ({got} chars, need {want})")
65 }
66 Self::Pwned { occurrences } => write!(
67 f,
68 "password appears in {occurrences} known data breaches; choose a different password"
69 ),
70 }
71 }
72}
73
74pub const MIN_PASSWORD_LEN: usize = 10;
79
80pub fn validate_length(password: &str) -> Result<(), PasswordPolicyError> {
83 let n = password.chars().count();
84 if n < MIN_PASSWORD_LEN {
85 return Err(PasswordPolicyError::TooShort {
86 got: n,
87 want: MIN_PASSWORD_LEN,
88 });
89 }
90 Ok(())
91}
92
93pub fn check_pwned(password: &str) -> Result<u64, String> {
106 let hash = sha1_hex_upper(password.as_bytes());
107 let (prefix, suffix) = hash.split_at(5);
108 let url = format!("https://api.pwnedpasswords.com/range/{prefix}");
109 let agent = ureq::AgentBuilder::new()
110 .timeout_connect(std::time::Duration::from_secs(5))
111 .timeout_read(std::time::Duration::from_secs(5))
112 .user_agent("pylon-auth")
113 .build();
114 let body = agent
115 .get(&url)
116 .set("Add-Padding", "true")
120 .call()
121 .map_err(|e| format!("hibp request: {e}"))?
122 .into_string()
123 .map_err(|e| format!("hibp body: {e}"))?;
124 Ok(parse_hibp_range(&body, suffix))
125}
126
127fn parse_hibp_range(body: &str, suffix: &str) -> u64 {
130 for line in body.lines() {
131 let line = line.trim();
132 let Some((s, c)) = line.split_once(':') else {
133 continue;
134 };
135 if s.eq_ignore_ascii_case(suffix) {
136 return c.trim().parse().unwrap_or(0);
137 }
138 }
139 0
140}
141
142fn sha1_hex_upper(input: &[u8]) -> String {
145 use sha1::{Digest, Sha1};
146 let mut h = Sha1::new();
147 h.update(input);
148 let out = h.finalize();
149 let mut s = String::with_capacity(40);
150 for b in out {
151 use std::fmt::Write;
152 let _ = write!(s, "{b:02X}");
153 }
154 s
155}
156
157pub fn validate(password: &str) -> Result<(), PasswordPolicyError> {
160 validate_length(password)?;
161 match check_pwned(password) {
162 Ok(0) => Ok(()),
163 Ok(n) => Err(PasswordPolicyError::Pwned { occurrences: n }),
164 Err(_) => Ok(()), }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::*;
171
172 #[test]
173 fn validate_length_rejects_short() {
174 let err = validate_length("short").unwrap_err();
175 assert!(matches!(err, PasswordPolicyError::TooShort { .. }));
176 }
177
178 #[test]
179 fn validate_length_accepts_min_len() {
180 assert!(validate_length("0123456789").is_ok());
181 }
182
183 #[test]
184 fn sha1_known_vector() {
185 let h = sha1_hex_upper(b"password");
187 assert_eq!(h, "5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8");
188 }
189
190 #[test]
191 fn parse_hibp_range_finds_match() {
192 let body = "0018A45C4D1DEF81644B54AB7F969B88D65:1\r\n\
194 003D68EB55068C33ACE09247EE4C639306B:3\r\n\
195 012345678901234567890123456789012345:42\r\n";
196 assert_eq!(
197 parse_hibp_range(body, "012345678901234567890123456789012345"),
198 42
199 );
200 assert_eq!(
201 parse_hibp_range(body, "0018A45C4D1DEF81644B54AB7F969B88D65"),
202 1
203 );
204 assert_eq!(
205 parse_hibp_range(body, "ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEF"),
206 0
207 );
208 }
209
210 #[test]
211 fn parse_hibp_range_is_case_insensitive() {
212 let body = "ABCDEF0123456789ABCDEF0123456789ABCD:7\r\n";
213 assert_eq!(
215 parse_hibp_range(body, "abcdef0123456789abcdef0123456789abcd"),
216 7
217 );
218 }
219}