Skip to main content

modo_auth/
password.rs

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/// Configuration for Argon2id password hashing.
10///
11/// Defaults follow OWASP recommendations for Argon2id:
12/// 19 MiB memory, 2 iterations, 1 degree of parallelism.
13///
14/// Can be deserialized from YAML/TOML with partial overrides — unset fields
15/// fall back to their defaults.
16#[derive(Debug, Clone, Deserialize)]
17#[serde(default)]
18pub struct PasswordConfig {
19    /// Memory cost in kibibytes (default: 19456 — 19 MiB).
20    pub memory_cost_kib: u32,
21    /// Number of iterations (default: 2).
22    pub time_cost: u32,
23    /// Degree of parallelism (default: 1).
24    pub parallelism: u32,
25}
26
27impl Default for PasswordConfig {
28    fn default() -> Self {
29        Self {
30            memory_cost_kib: 19456, // 19 MiB
31            time_cost: 2,
32            parallelism: 1,
33        }
34    }
35}
36
37/// Argon2id password hashing service.
38///
39/// Construct with [`PasswordConfig`] (or use `Default` for OWASP-recommended settings),
40/// register with `app.service(hasher)`, and extract in handlers via
41/// `modo::Service<PasswordHasher>`.
42///
43/// Both [`hash_password`](PasswordHasher::hash_password) and
44/// [`verify_password`](PasswordHasher::verify_password) run on a blocking thread
45/// via `tokio::task::spawn_blocking` to avoid stalling the async runtime.
46#[derive(Debug, Clone)]
47pub struct PasswordHasher {
48    params: Params,
49}
50
51impl PasswordHasher {
52    /// Create a new hasher with the given Argon2id parameters.
53    ///
54    /// Returns an error if the parameter values are invalid (e.g., zero memory or parallelism).
55    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    /// Hash a password using Argon2id with a random salt.
68    ///
69    /// Returns a PHC-formatted string that embeds the algorithm, parameters, salt,
70    /// and hash. Each call produces a unique output even for the same input.
71    ///
72    /// Runs on a blocking thread to avoid stalling the Tokio runtime.
73    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    /// Verify a password against a PHC-formatted hash string.
95    ///
96    /// Returns `Ok(true)` on match, `Ok(false)` on mismatch.
97    /// Returns `Err` only for malformed hash strings.
98    ///
99    /// The parameters embedded in the hash are used for verification, not
100    /// the parameters this hasher was constructed with.
101    ///
102    /// Runs on a blocking thread to avoid stalling the Tokio runtime.
103    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            // Note: argon2's verify_password uses params from the parsed hash,
114            // not from the Argon2 instance — but we pass self.params for consistency.
115            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(_) => {} // error already in the Result
137        }
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); // default
223        assert_eq!(config.parallelism, 1); // default
224    }
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}