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 tokio::task::spawn_blocking(move || {
78 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
79 let salt = SaltString::generate(&mut OsRng);
80
81 argon2
82 .hash_password(password.as_bytes(), &salt)
83 .map(|h| h.to_string())
84 .map_err(|e| modo::Error::internal(format!("password hashing failed: {e}")))
85 })
86 .await
87 .map_err(|e| modo::Error::internal(format!("password hashing task failed: {e}")))?
88 }
89
90 pub async fn verify_password(&self, password: &str, hash: &str) -> Result<bool, modo::Error> {
100 let params = self.params.clone();
101 let password = password.to_owned();
102 let hash = hash.to_owned();
103
104 tokio::task::spawn_blocking(move || {
105 let parsed = PasswordHash::new(&hash)
106 .map_err(|e| modo::Error::internal(format!("invalid password hash: {e}")))?;
107
108 match Argon2::new(Algorithm::Argon2id, Version::V0x13, params)
111 .verify_password(password.as_bytes(), &parsed)
112 {
113 Ok(()) => Ok(true),
114 Err(argon2::password_hash::Error::Password) => Ok(false),
115 Err(e) => Err(modo::Error::internal(format!(
116 "password verification failed: {e}"
117 ))),
118 }
119 })
120 .await
121 .map_err(|e| modo::Error::internal(format!("password verification task failed: {e}")))?
122 }
123}
124
125impl Default for PasswordHasher {
126 fn default() -> Self {
127 Self::new(PasswordConfig::default()).expect("default PasswordConfig is valid")
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 use super::*;
134
135 #[tokio::test]
136 async fn hash_and_verify_roundtrip() {
137 let hasher = PasswordHasher::default();
138 let hash = hasher
139 .hash_password("correct-horse-battery-staple")
140 .await
141 .unwrap();
142 assert!(
143 hasher
144 .verify_password("correct-horse-battery-staple", &hash)
145 .await
146 .unwrap()
147 );
148 }
149
150 #[tokio::test]
151 async fn verify_wrong_password() {
152 let hasher = PasswordHasher::default();
153 let hash = hasher.hash_password("correct-password").await.unwrap();
154 assert!(
155 !hasher
156 .verify_password("wrong-password", &hash)
157 .await
158 .unwrap()
159 );
160 }
161
162 #[tokio::test]
163 async fn verify_invalid_hash() {
164 let hasher = PasswordHasher::default();
165 assert!(
166 hasher
167 .verify_password("password", "not-a-valid-hash")
168 .await
169 .is_err()
170 );
171 }
172
173 #[tokio::test]
174 async fn hash_produces_unique_outputs() {
175 let hasher = PasswordHasher::default();
176 let h1 = hasher.hash_password("same-password").await.unwrap();
177 let h2 = hasher.hash_password("same-password").await.unwrap();
178 assert_ne!(h1, h2);
179 }
180
181 #[test]
182 fn invalid_config_rejected() {
183 let config = PasswordConfig {
184 memory_cost_kib: 0,
185 time_cost: 0,
186 parallelism: 0,
187 };
188 assert!(PasswordHasher::new(config).is_err());
189 }
190
191 #[test]
192 fn default_config_values() {
193 let config = PasswordConfig::default();
194 assert_eq!(config.memory_cost_kib, 19456);
195 assert_eq!(config.time_cost, 2);
196 assert_eq!(config.parallelism, 1);
197 }
198
199 #[test]
200 fn partial_yaml_deserialization() {
201 let yaml = "memory_cost_kib: 32768";
202 let config: PasswordConfig = serde_yaml_ng::from_str(yaml).unwrap();
203 assert_eq!(config.memory_cost_kib, 32768);
204 assert_eq!(config.time_cost, 2); assert_eq!(config.parallelism, 1); }
207
208 #[tokio::test]
209 async fn hash_with_custom_config() {
210 let config = PasswordConfig {
211 memory_cost_kib: 8192,
212 time_cost: 1,
213 parallelism: 1,
214 };
215 let hasher = PasswordHasher::new(config).unwrap();
216 let hash = hasher.hash_password("test-password").await.unwrap();
217 assert!(
218 hasher
219 .verify_password("test-password", &hash)
220 .await
221 .unwrap()
222 );
223 }
224}