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}