acton_htmx/auth/password.rs
1//! Password hashing and verification using Argon2id
2//!
3//! This module provides secure password hashing using the Argon2id algorithm,
4//! which is resistant to both side-channel and GPU-based attacks.
5//!
6//! # Security Considerations
7//!
8//! - Uses Argon2id (hybrid mode) for balanced security
9//! - Configurable memory cost, iterations, and parallelism
10//! - Cryptographically secure random salt generation
11//! - Constant-time password verification
12//! - Follows OWASP recommendations for password storage
13//!
14//! # Example
15//!
16//! ```rust
17//! use acton_htmx::auth::password::{PasswordHasher, hash_password, verify_password};
18//!
19//! # fn example() -> anyhow::Result<()> {
20//! // Hash a password with default parameters
21//! let password = "correct-horse-battery-staple";
22//! let hash = hash_password(password)?;
23//!
24//! // Verify password
25//! assert!(verify_password(password, &hash)?);
26//! assert!(!verify_password("wrong-password", &hash)?);
27//!
28//! // Use custom parameters
29//! let hasher = PasswordHasher::builder()
30//! .memory_cost(32 * 1024) // 32 MB
31//! .iterations(3)
32//! .parallelism(2)
33//! .build()?;
34//!
35//! let hash = hasher.hash(password)?;
36//! assert!(hasher.verify(password, &hash)?);
37//! # Ok(())
38//! # }
39//! ```
40
41use argon2::{
42 password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher as _, PasswordVerifier, SaltString},
43 Argon2, Params, Version,
44};
45use serde::{Deserialize, Serialize};
46use thiserror::Error;
47
48/// Password hashing errors
49#[derive(Debug, Error)]
50pub enum PasswordError {
51 /// Failed to hash password
52 #[error("Failed to hash password: {0}")]
53 HashingFailed(String),
54
55 /// Failed to verify password
56 #[error("Failed to verify password: {0}")]
57 VerificationFailed(String),
58
59 /// Invalid password hash format
60 #[error("Invalid password hash format: {0}")]
61 InvalidHash(String),
62
63 /// Invalid parameters for Argon2
64 #[error("Invalid Argon2 parameters: {0}")]
65 InvalidParams(String),
66}
67
68/// Configuration for Argon2id password hashing
69///
70/// These parameters control the computational cost of hashing and verification.
71/// Higher values provide better security but require more resources.
72///
73/// # Defaults
74///
75/// The defaults follow OWASP recommendations for server-side password hashing:
76/// - Memory cost: 19456 KiB (~19 MB)
77/// - Iterations: 2
78/// - Parallelism: 1
79/// - Output length: 32 bytes
80#[derive(Debug, Clone, Serialize, Deserialize)]
81#[serde(default)]
82pub struct PasswordHashConfig {
83 /// Memory cost in KiB (default: 19456 = ~19 MB)
84 ///
85 /// Higher values make attacks more expensive. OWASP recommends at least 12 MiB.
86 pub memory_cost: u32,
87
88 /// Number of iterations (default: 2)
89 ///
90 /// Higher values increase computation time. OWASP recommends at least 2.
91 pub iterations: u32,
92
93 /// Degree of parallelism (default: 1)
94 ///
95 /// Number of parallel threads to use. Typically set to available CPU cores.
96 pub parallelism: u32,
97
98 /// Output hash length in bytes (default: 32)
99 pub output_length: usize,
100}
101
102impl Default for PasswordHashConfig {
103 fn default() -> Self {
104 Self {
105 memory_cost: 19456, // 19 MB (OWASP recommended minimum)
106 iterations: 2, // OWASP recommended minimum
107 parallelism: 1, // Single-threaded by default
108 output_length: 32, // 256 bits
109 }
110 }
111}
112
113/// Password hasher using Argon2id
114///
115/// Provides secure password hashing with configurable parameters.
116/// All methods are constant-time to prevent timing attacks.
117#[derive(Clone, Default)]
118pub struct PasswordHasher {
119 config: PasswordHashConfig,
120}
121
122impl PasswordHasher {
123 /// Create a new password hasher with default parameters
124 ///
125 /// # Example
126 ///
127 /// ```rust
128 /// use acton_htmx::auth::password::PasswordHasher;
129 ///
130 /// let hasher = PasswordHasher::new();
131 /// ```
132 #[must_use]
133 pub fn new() -> Self {
134 Self::default()
135 }
136
137 /// Create a password hasher with custom configuration
138 ///
139 /// # Example
140 ///
141 /// ```rust
142 /// use acton_htmx::auth::password::{PasswordHasher, PasswordHashConfig};
143 ///
144 /// let config = PasswordHashConfig {
145 /// memory_cost: 32 * 1024, // 32 MB
146 /// iterations: 3,
147 /// parallelism: 2,
148 /// output_length: 32,
149 /// };
150 ///
151 /// let hasher = PasswordHasher::with_config(config);
152 /// ```
153 #[must_use]
154 pub const fn with_config(config: PasswordHashConfig) -> Self {
155 Self { config }
156 }
157
158 /// Create a builder for configuring password hasher parameters
159 ///
160 /// # Example
161 ///
162 /// ```rust
163 /// use acton_htmx::auth::password::PasswordHasher;
164 ///
165 /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
166 /// let hasher = PasswordHasher::builder()
167 /// .memory_cost(32 * 1024)
168 /// .iterations(3)
169 /// .parallelism(2)
170 /// .build()?;
171 /// # Ok(())
172 /// # }
173 /// ```
174 #[must_use]
175 pub fn builder() -> PasswordHasherBuilder {
176 PasswordHasherBuilder::new()
177 }
178
179 /// Hash a password using Argon2id
180 ///
181 /// # Errors
182 ///
183 /// Returns error if:
184 /// - Random number generation fails
185 /// - Parameters are invalid
186 /// - Hashing operation fails
187 ///
188 /// # Example
189 ///
190 /// ```rust
191 /// use acton_htmx::auth::password::PasswordHasher;
192 ///
193 /// # fn example() -> anyhow::Result<()> {
194 /// let hasher = PasswordHasher::new();
195 /// let hash = hasher.hash("my-secret-password")?;
196 /// println!("Password hash: {}", hash);
197 /// # Ok(())
198 /// # }
199 /// ```
200 pub fn hash(&self, password: &str) -> Result<String, PasswordError> {
201 // Generate cryptographically secure random salt
202 let salt = SaltString::generate(&mut OsRng);
203
204 // Configure Argon2 parameters
205 let params = Params::new(
206 self.config.memory_cost,
207 self.config.iterations,
208 self.config.parallelism,
209 Some(self.config.output_length),
210 )
211 .map_err(|e| PasswordError::InvalidParams(e.to_string()))?;
212
213 // Create Argon2 instance with parameters
214 let argon2 = Argon2::new(
215 argon2::Algorithm::Argon2id, // Hybrid mode: resistant to both side-channel and GPU attacks
216 Version::V0x13, // Latest version
217 params,
218 );
219
220 // Hash the password
221 let password_hash = argon2
222 .hash_password(password.as_bytes(), &salt)
223 .map_err(|e| PasswordError::HashingFailed(e.to_string()))?;
224
225 Ok(password_hash.to_string())
226 }
227
228 /// Verify a password against a hash
229 ///
230 /// Uses constant-time comparison to prevent timing attacks.
231 ///
232 /// # Errors
233 ///
234 /// Returns error if:
235 /// - Hash format is invalid
236 /// - Verification operation fails
237 ///
238 /// # Example
239 ///
240 /// ```rust
241 /// use acton_htmx::auth::password::PasswordHasher;
242 ///
243 /// # fn example() -> anyhow::Result<()> {
244 /// let hasher = PasswordHasher::new();
245 /// let hash = hasher.hash("correct-password")?;
246 ///
247 /// assert!(hasher.verify("correct-password", &hash)?);
248 /// assert!(!hasher.verify("wrong-password", &hash)?);
249 /// # Ok(())
250 /// # }
251 /// ```
252 pub fn verify(&self, password: &str, hash: &str) -> Result<bool, PasswordError> {
253 // Parse the PHC string (Password Hashing Competition format)
254 let parsed_hash =
255 PasswordHash::new(hash).map_err(|e| PasswordError::InvalidHash(e.to_string()))?;
256
257 // Create Argon2 instance (parameters are read from the hash string)
258 let argon2 = Argon2::default();
259
260 // Verify password (constant-time comparison)
261 match argon2.verify_password(password.as_bytes(), &parsed_hash) {
262 Ok(()) => Ok(true),
263 Err(argon2::password_hash::Error::Password) => Ok(false), // Wrong password
264 Err(e) => Err(PasswordError::VerificationFailed(e.to_string())),
265 }
266 }
267
268 /// Get the current configuration
269 #[must_use]
270 pub const fn config(&self) -> &PasswordHashConfig {
271 &self.config
272 }
273}
274
275/// Builder for `PasswordHasher`
276///
277/// Provides a fluent interface for configuring password hashing parameters.
278#[derive(Default)]
279pub struct PasswordHasherBuilder {
280 config: PasswordHashConfig,
281}
282
283impl PasswordHasherBuilder {
284 /// Create a new builder with default parameters
285 #[must_use]
286 pub fn new() -> Self {
287 Self::default()
288 }
289
290 /// Set memory cost in KiB
291 ///
292 /// OWASP recommends at least 12 MiB (12288 KiB).
293 #[must_use]
294 pub const fn memory_cost(mut self, cost: u32) -> Self {
295 self.config.memory_cost = cost;
296 self
297 }
298
299 /// Set number of iterations
300 ///
301 /// OWASP recommends at least 2.
302 #[must_use]
303 pub const fn iterations(mut self, iterations: u32) -> Self {
304 self.config.iterations = iterations;
305 self
306 }
307
308 /// Set degree of parallelism
309 ///
310 /// Typically set to the number of available CPU cores.
311 #[must_use]
312 pub const fn parallelism(mut self, parallelism: u32) -> Self {
313 self.config.parallelism = parallelism;
314 self
315 }
316
317 /// Set output hash length in bytes
318 #[must_use]
319 pub const fn output_length(mut self, length: usize) -> Self {
320 self.config.output_length = length;
321 self
322 }
323
324 /// Build the password hasher
325 ///
326 /// # Errors
327 ///
328 /// Returns error if parameters are invalid
329 pub fn build(self) -> Result<PasswordHasher, PasswordError> {
330 // Validate parameters by attempting to create Params
331 Params::new(
332 self.config.memory_cost,
333 self.config.iterations,
334 self.config.parallelism,
335 Some(self.config.output_length),
336 )
337 .map_err(|e| PasswordError::InvalidParams(e.to_string()))?;
338
339 Ok(PasswordHasher {
340 config: self.config,
341 })
342 }
343}
344
345/// Hash a password using default Argon2id parameters
346///
347/// This is a convenience function that uses `PasswordHasher::default()`.
348///
349/// # Errors
350///
351/// Returns error if hashing fails
352///
353/// # Example
354///
355/// ```rust
356/// use acton_htmx::auth::password::hash_password;
357///
358/// # fn example() -> anyhow::Result<()> {
359/// let hash = hash_password("my-secret-password")?;
360/// println!("Password hash: {}", hash);
361/// # Ok(())
362/// # }
363/// ```
364pub fn hash_password(password: &str) -> Result<String, PasswordError> {
365 PasswordHasher::default().hash(password)
366}
367
368/// Verify a password against a hash using default parameters
369///
370/// This is a convenience function that uses `PasswordHasher::default()`.
371///
372/// # Errors
373///
374/// Returns error if verification fails
375///
376/// # Example
377///
378/// ```rust
379/// use acton_htmx::auth::password::{hash_password, verify_password};
380///
381/// # fn example() -> anyhow::Result<()> {
382/// let hash = hash_password("correct-password")?;
383///
384/// assert!(verify_password("correct-password", &hash)?);
385/// assert!(!verify_password("wrong-password", &hash)?);
386/// # Ok(())
387/// # }
388/// ```
389pub fn verify_password(password: &str, hash: &str) -> Result<bool, PasswordError> {
390 PasswordHasher::default().verify(password, hash)
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn test_password_hashing() {
399 let hasher = PasswordHasher::new();
400 let password = "test-password-123";
401
402 let hash = hasher.hash(password).expect("Failed to hash password");
403
404 // Hash should be in PHC string format
405 assert!(hash.starts_with("$argon2id$"));
406
407 // Should verify correctly
408 assert!(hasher
409 .verify(password, &hash)
410 .expect("Failed to verify password"));
411
412 // Wrong password should fail
413 assert!(!hasher
414 .verify("wrong-password", &hash)
415 .expect("Failed to verify wrong password"));
416 }
417
418 #[test]
419 fn test_convenience_functions() {
420 let password = "test-password-456";
421 let hash = hash_password(password).expect("Failed to hash");
422
423 assert!(verify_password(password, &hash).expect("Failed to verify"));
424 assert!(!verify_password("wrong", &hash).expect("Failed to verify wrong"));
425 }
426
427 #[test]
428 fn test_custom_parameters() {
429 let hasher = PasswordHasher::builder()
430 .memory_cost(16 * 1024) // 16 MB
431 .iterations(3)
432 .parallelism(2)
433 .build()
434 .expect("Failed to build hasher");
435
436 let password = "custom-params-test";
437 let hash = hasher.hash(password).expect("Failed to hash");
438
439 assert!(hasher.verify(password, &hash).expect("Failed to verify"));
440 }
441
442 #[test]
443 fn test_invalid_hash_format() {
444 let hasher = PasswordHasher::new();
445 let result = hasher.verify("password", "invalid-hash");
446
447 assert!(result.is_err());
448 assert!(matches!(result.unwrap_err(), PasswordError::InvalidHash(_)));
449 }
450
451 #[test]
452 fn test_different_hashes_for_same_password() {
453 let hasher = PasswordHasher::new();
454 let password = "same-password";
455
456 let hash1 = hasher.hash(password).expect("Failed to hash 1");
457 let hash2 = hasher.hash(password).expect("Failed to hash 2");
458
459 // Different salts = different hashes
460 assert_ne!(hash1, hash2);
461
462 // Both should verify
463 assert!(hasher.verify(password, &hash1).expect("Failed to verify 1"));
464 assert!(hasher.verify(password, &hash2).expect("Failed to verify 2"));
465 }
466
467 #[test]
468 fn test_default_config() {
469 let config = PasswordHashConfig::default();
470 assert_eq!(config.memory_cost, 19456); // ~19 MB
471 assert_eq!(config.iterations, 2);
472 assert_eq!(config.parallelism, 1);
473 assert_eq!(config.output_length, 32);
474 }
475
476 #[test]
477 fn test_builder_pattern() {
478 let hasher = PasswordHasher::builder()
479 .memory_cost(20000)
480 .iterations(4)
481 .parallelism(4)
482 .output_length(64)
483 .build()
484 .expect("Failed to build");
485
486 assert_eq!(hasher.config().memory_cost, 20000);
487 assert_eq!(hasher.config().iterations, 4);
488 assert_eq!(hasher.config().parallelism, 4);
489 assert_eq!(hasher.config().output_length, 64);
490 }
491
492 #[test]
493 fn test_invalid_parameters() {
494 // Memory cost too low
495 let result = PasswordHasher::builder().memory_cost(1).build();
496 assert!(result.is_err());
497
498 // Iterations too low
499 let result = PasswordHasher::builder().iterations(0).build();
500 assert!(result.is_err());
501 }
502
503 #[test]
504 fn test_constant_time_verification() {
505 // This test ensures the API supports constant-time verification,
506 // though the actual constant-time behavior is provided by the argon2 crate
507 let hasher = PasswordHasher::new();
508 let hash = hasher.hash("test").expect("Failed to hash");
509
510 // Both operations should complete without revealing timing info
511 let _ = hasher.verify("test", &hash);
512 let _ = hasher.verify("wrong", &hash);
513 }
514}