arcly_http_identity/
password.rs1use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString};
9use argon2::Argon2;
10use async_trait::async_trait;
11use rand::rngs::OsRng;
12
13use crate::error::{IdentityError, Result};
14
15#[async_trait]
18pub trait PasswordHasher: Send + Sync + 'static {
19 async fn hash(&self, password: String) -> Result<String>;
21
22 async fn verify(&self, password: String, phc: String) -> Result<bool>;
25
26 fn needs_rehash(&self, phc: &str) -> bool;
30}
31
32pub struct Argon2idHasher {
36 argon2: Argon2<'static>,
37}
38
39impl Default for Argon2idHasher {
40 fn default() -> Self {
41 Self::new()
42 }
43}
44
45impl Argon2idHasher {
46 pub fn new() -> Self {
47 Self {
49 argon2: Argon2::default(),
50 }
51 }
52
53 fn is_bcrypt(phc: &str) -> bool {
54 phc.starts_with("$2a$") || phc.starts_with("$2b$") || phc.starts_with("$2y$")
55 }
56}
57
58#[async_trait]
59impl PasswordHasher for Argon2idHasher {
60 async fn hash(&self, password: String) -> Result<String> {
61 let argon2 = self.argon2.clone();
63 tokio::task::spawn_blocking(move || {
64 let salt = SaltString::generate(&mut OsRng);
65 argon2
66 .hash_password(password.as_bytes(), &salt)
67 .map(|h| h.to_string())
68 .map_err(|e| IdentityError::Internal(format!("argon2 hash: {e}")))
69 })
70 .await
71 .map_err(|e| IdentityError::Internal(format!("join: {e}")))?
72 }
73
74 async fn verify(&self, password: String, phc: String) -> Result<bool> {
75 let argon2 = self.argon2.clone();
76 tokio::task::spawn_blocking(move || {
77 if Self::is_bcrypt(&phc) {
78 return Ok(bcrypt::verify(&password, &phc).unwrap_or(false));
80 }
81 match PasswordHash::new(&phc) {
82 Ok(parsed) => Ok(argon2.verify_password(password.as_bytes(), &parsed).is_ok()),
83 Err(_) => Ok(false),
86 }
87 })
88 .await
89 .map_err(|e| IdentityError::Internal(format!("join: {e}")))?
90 }
91
92 fn needs_rehash(&self, phc: &str) -> bool {
93 Self::is_bcrypt(phc)
96 }
97}
98
99#[derive(Debug, Clone)]
105pub struct PasswordPolicy {
106 pub min_len: usize,
107 pub max_len: usize,
108 pub require_upper: bool,
109 pub require_lower: bool,
110 pub require_digit: bool,
111 pub require_symbol: bool,
112}
113
114impl Default for PasswordPolicy {
115 fn default() -> Self {
116 Self {
118 min_len: 12,
119 max_len: 4096,
120 require_upper: false,
121 require_lower: false,
122 require_digit: false,
123 require_symbol: false,
124 }
125 }
126}
127
128impl PasswordPolicy {
129 pub fn validate(&self, password: &str) -> Result<()> {
132 let len = password.chars().count();
133 if len < self.min_len {
134 return Err(reject(format!(
135 "must be at least {} characters",
136 self.min_len
137 )));
138 }
139 if len > self.max_len {
140 return Err(reject(format!(
141 "must be at most {} characters",
142 self.max_len
143 )));
144 }
145 if self.require_upper && !password.chars().any(|c| c.is_ascii_uppercase()) {
146 return Err(reject("must contain an uppercase letter"));
147 }
148 if self.require_lower && !password.chars().any(|c| c.is_ascii_lowercase()) {
149 return Err(reject("must contain a lowercase letter"));
150 }
151 if self.require_digit && !password.chars().any(|c| c.is_ascii_digit()) {
152 return Err(reject("must contain a digit"));
153 }
154 if self.require_symbol && password.chars().all(|c| c.is_alphanumeric()) {
155 return Err(reject("must contain a symbol"));
156 }
157 Ok(())
158 }
159}
160
161fn reject(msg: impl Into<String>) -> IdentityError {
162 IdentityError::PasswordRejected(msg.into())
163}
164
165#[async_trait::async_trait]
170pub trait BreachChecker: Send + Sync + 'static {
171 async fn is_breached(&self, password: &str) -> Result<bool>;
172}
173
174#[cfg(test)]
175mod tests {
176 use super::*;
177
178 #[test]
179 fn policy_enforces_length_and_composition() {
180 let p = PasswordPolicy::default();
181 assert!(p.validate("short").is_err());
182 assert!(p.validate("a-sufficiently-long-passphrase").is_ok());
183
184 let strict = PasswordPolicy {
185 min_len: 8,
186 require_upper: true,
187 require_digit: true,
188 require_symbol: true,
189 ..PasswordPolicy::default()
190 };
191 assert!(strict.validate("alllowercase").is_err());
192 assert!(strict.validate("Str0ng!pass").is_ok());
193 }
194
195 #[tokio::test]
196 async fn argon2_roundtrip() {
197 let h = Argon2idHasher::new();
198 let phc = h.hash("s3cr3t".into()).await.unwrap();
199 assert!(h.verify("s3cr3t".into(), phc.clone()).await.unwrap());
200 assert!(!h.verify("wrong".into(), phc.clone()).await.unwrap());
201 assert!(!h.needs_rehash(&phc));
202 }
203
204 #[tokio::test]
205 async fn bcrypt_verifies_and_flags_rehash() {
206 let h = Argon2idHasher::new();
207 let bcrypt_hash = bcrypt::hash("legacy", 4).unwrap();
208 assert!(h
209 .verify("legacy".into(), bcrypt_hash.clone())
210 .await
211 .unwrap());
212 assert!(h.needs_rehash(&bcrypt_hash));
213 }
214}