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        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    /// Verify a password against a PHC-formatted hash string.
91    ///
92    /// Returns `Ok(true)` on match, `Ok(false)` on mismatch.
93    /// Returns `Err` only for malformed hash strings.
94    ///
95    /// The parameters embedded in the hash are used for verification, not
96    /// the parameters this hasher was constructed with.
97    ///
98    /// Runs on a blocking thread to avoid stalling the Tokio runtime.
99    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            // Note: argon2's verify_password uses params from the parsed hash,
109            // not from the Argon2 instance — but we pass self.params for consistency.
110            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); // default
205        assert_eq!(config.parallelism, 1); // default
206    }
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}