1use argon2::{
2 Algorithm, Argon2, Params, Version,
3 password_hash::{
4 PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString, rand_core::OsRng,
5 },
6};
7use serde::Deserialize;
8
9#[derive(Debug, Clone, Deserialize)]
17#[serde(default)]
18pub struct PasswordConfig {
19 pub memory_cost_kib: u32,
21 pub time_cost: u32,
23 pub parallelism: u32,
25}
26
27impl Default for PasswordConfig {
28 fn default() -> Self {
29 Self {
30 memory_cost_kib: 19456, time_cost: 2,
32 parallelism: 1,
33 }
34 }
35}
36
37#[derive(Debug, Clone)]
47pub struct PasswordHasher {
48 params: Params,
49}
50
51impl PasswordHasher {
52 pub fn new(config: PasswordConfig) -> Result<Self, modo::Error> {
56 let params = Params::new(
57 config.memory_cost_kib,
58 config.time_cost,
59 config.parallelism,
60 None,
61 )
62 .map_err(|e| modo::Error::internal(format!("invalid argon2 params: {e}")))?;
63
64 Ok(Self { params })
65 }
66
67 pub async fn hash_password(&self, password: &str) -> Result<String, modo::Error> {
74 let params = self.params.clone();
75 let password = password.to_owned();
76
77 let start = std::time::Instant::now();
78 let result = tokio::task::spawn_blocking(move || {
79 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
80 let salt = SaltString::generate(&mut OsRng);
81
82 argon2
83 .hash_password(password.as_bytes(), &salt)
84 .map(|h| h.to_string())
85 .map_err(|e| modo::Error::internal(format!("password hashing failed: {e}")))
86 })
87 .await
88 .map_err(|e| modo::Error::internal(format!("password hashing task failed: {e}")))?;
89
90 tracing::debug!(duration_ms = %start.elapsed().as_millis(), "password hash completed");
91 result
92 }
93
94 pub async fn verify_password(&self, password: &str, hash: &str) -> Result<bool, modo::Error> {
104 let params = self.params.clone();
105 let password = password.to_owned();
106 let hash = hash.to_owned();
107
108 let start = std::time::Instant::now();
109 let result = tokio::task::spawn_blocking(move || {
110 let parsed = PasswordHash::new(&hash)
111 .map_err(|e| modo::Error::internal(format!("invalid password hash: {e}")))?;
112
113 match Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
116 .verify_password(password.as_bytes(), &parsed)
117 {
118 Ok(()) => Ok(true),
119 Err(argon2::password_hash::Error::Password) => Ok(false),
120 Err(e) => Err(modo::Error::internal(format!(
121 "password verification failed: {e}"
122 ))),
123 }
124 })
125 .await
126 .map_err(|e| modo::Error::internal(format!("password verification task failed: {e}")))?;
127
128 let elapsed_ms = start.elapsed().as_millis();
129 match &result {
130 Ok(true) => {
131 tracing::debug!(duration_ms = %elapsed_ms, "password verification succeeded")
132 }
133 Ok(false) => {
134 tracing::debug!(duration_ms = %elapsed_ms, "password verification failed (mismatch)")
135 }
136 Err(_) => {} }
138
139 result
140 }
141}
142
143impl Default for PasswordHasher {
144 fn default() -> Self {
145 Self::new(PasswordConfig::default()).expect("default PasswordConfig is valid")
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[tokio::test]
154 async fn hash_and_verify_roundtrip() {
155 let hasher = PasswordHasher::default();
156 let hash = hasher
157 .hash_password("correct-horse-battery-staple")
158 .await
159 .unwrap();
160 assert!(
161 hasher
162 .verify_password("correct-horse-battery-staple", &hash)
163 .await
164 .unwrap()
165 );
166 }
167
168 #[tokio::test]
169 async fn verify_wrong_password() {
170 let hasher = PasswordHasher::default();
171 let hash = hasher.hash_password("correct-password").await.unwrap();
172 assert!(
173 !hasher
174 .verify_password("wrong-password", &hash)
175 .await
176 .unwrap()
177 );
178 }
179
180 #[tokio::test]
181 async fn verify_invalid_hash() {
182 let hasher = PasswordHasher::default();
183 assert!(
184 hasher
185 .verify_password("password", "not-a-valid-hash")
186 .await
187 .is_err()
188 );
189 }
190
191 #[tokio::test]
192 async fn hash_produces_unique_outputs() {
193 let hasher = PasswordHasher::default();
194 let h1 = hasher.hash_password("same-password").await.unwrap();
195 let h2 = hasher.hash_password("same-password").await.unwrap();
196 assert_ne!(h1, h2);
197 }
198
199 #[test]
200 fn invalid_config_rejected() {
201 let config = PasswordConfig {
202 memory_cost_kib: 0,
203 time_cost: 0,
204 parallelism: 0,
205 };
206 assert!(PasswordHasher::new(config).is_err());
207 }
208
209 #[test]
210 fn default_config_values() {
211 let config = PasswordConfig::default();
212 assert_eq!(config.memory_cost_kib, 19456);
213 assert_eq!(config.time_cost, 2);
214 assert_eq!(config.parallelism, 1);
215 }
216
217 #[test]
218 fn partial_yaml_deserialization() {
219 let yaml = "memory_cost_kib: 32768";
220 let config: PasswordConfig = serde_yaml_ng::from_str(yaml).unwrap();
221 assert_eq!(config.memory_cost_kib, 32768);
222 assert_eq!(config.time_cost, 2); assert_eq!(config.parallelism, 1); }
225
226 #[tokio::test]
227 async fn hash_with_custom_config() {
228 let config = PasswordConfig {
229 memory_cost_kib: 8192,
230 time_cost: 1,
231 parallelism: 1,
232 };
233 let hasher = PasswordHasher::new(config).unwrap();
234 let hash = hasher.hash_password("test-password").await.unwrap();
235 assert!(
236 hasher
237 .verify_password("test-password", &hash)
238 .await
239 .unwrap()
240 );
241 }
242}