Skip to main content

assay_auth/
password.rs

1//! Argon2id password hashing wrapper.
2//!
3//! Plan 11 reference: Argon2id with sensible defaults. We pick
4//! m=64 MiB, t=3, p=4 — comfortably above the OWASP 2024 minimum
5//! (m=19 MiB, t=2) and within reach of a small worker process. Tunable
6//! later via [`PasswordHasher::with_params`] when a deployment needs to
7//! shift the cost to match its hardware envelope.
8//!
9//! Hashes are stored as PHC-format strings (`$argon2id$v=19$...`) on
10//! `auth.users.password_hash`; the algorithm and parameters travel with
11//! the hash, so [`PasswordHasher::needs_rehash`] can detect drift across
12//! deployments and trigger an opportunistic re-hash on next successful
13//! login.
14
15use argon2::{Algorithm, Argon2, Params, Version};
16use password_hash::{Salt, PasswordHash, PasswordHasher as PhcHasher, PasswordVerifier, SaltString};
17use rand::RngCore;
18
19use crate::error::{Error, Result};
20
21/// Memory cost in KiB. 65 536 KiB = 64 MiB. Above OWASP 2024 minimum.
22const DEFAULT_MEMORY_KIB: u32 = 65_536;
23/// Time cost (number of passes). Three is a balance between
24/// brute-force resistance and login latency on commodity hardware.
25const DEFAULT_TIME_COST: u32 = 3;
26/// Parallelism. Four threads matches the typical small-server CPU
27/// shape; the implementation is internally parallel on supported
28/// platforms.
29const DEFAULT_PARALLELISM: u32 = 4;
30
31/// Stateless hasher — owns one [`Argon2`] context preconfigured with
32/// the active parameters. Cheap to clone (the inner `Argon2<'static>`
33/// holds borrowed references to the static parameter struct only).
34#[derive(Clone)]
35pub struct PasswordHasher {
36    argon: Argon2<'static>,
37    params: Params,
38}
39
40impl Default for PasswordHasher {
41    fn default() -> Self {
42        let params = Params::new(
43            DEFAULT_MEMORY_KIB,
44            DEFAULT_TIME_COST,
45            DEFAULT_PARALLELISM,
46            None,
47        )
48        .expect("argon2 default params are within library limits");
49        Self {
50            argon: Argon2::new(Algorithm::Argon2id, Version::V0x13, params.clone()),
51            params,
52        }
53    }
54}
55
56impl PasswordHasher {
57    /// Construct with explicit Argon2id parameters. Use the [`Default`]
58    /// impl for the standard cost; reach for this only when tuning to
59    /// non-standard hardware (e.g. a mobile-only deployment).
60    pub fn with_params(params: Params) -> Self {
61        Self {
62            argon: Argon2::new(Algorithm::Argon2id, Version::V0x13, params.clone()),
63            params,
64        }
65    }
66
67    /// Hash a plaintext password. The returned PHC string is what
68    /// [`crate::store::UserStore::set_password_hash`] persists.
69    ///
70    /// The salt is sourced from `rand::rng()` (which delegates to the
71    /// OS getrandom under the hood) so we don't need to depend on
72    /// `password-hash`'s rand_core 0.6 + `getrandom` feature pair just
73    /// for the salt — `rand` 0.9 is already a direct dependency.
74    pub fn hash(&self, plaintext: &str) -> Result<String> {
75        let mut salt_bytes = [0u8; Salt::RECOMMENDED_LENGTH];
76        rand::rng().fill_bytes(&mut salt_bytes);
77        let salt = SaltString::encode_b64(&salt_bytes).map_err(map_phc_err)?;
78        let phc = self
79            .argon
80            .hash_password(plaintext.as_bytes(), &salt)
81            .map_err(map_phc_err)?;
82        Ok(phc.to_string())
83    }
84
85    /// Verify a plaintext password against a stored PHC hash.
86    ///
87    /// Returns:
88    /// - `Ok(true)` if the password matches.
89    /// - `Ok(false)` if the password is wrong (but the stored hash
90    ///   parsed cleanly).
91    /// - `Err(Error::Backend(_))` if the stored hash is malformed.
92    pub fn verify(&self, plaintext: &str, phc: &str) -> Result<bool> {
93        let parsed = PasswordHash::new(phc).map_err(map_phc_err)?;
94        match self.argon.verify_password(plaintext.as_bytes(), &parsed) {
95            Ok(()) => Ok(true),
96            // `Password` variant means parsing succeeded but the digest
97            // doesn't match — that's a "wrong password", not a system
98            // error. Anything else (e.g. unsupported algorithm) is real.
99            Err(password_hash::Error::Password) => Ok(false),
100            Err(other) => Err(map_phc_err(other)),
101        }
102    }
103
104    /// Returns `true` when the stored hash was produced with parameters
105    /// that differ from the current configuration. Callers should
106    /// re-hash on the next successful login so the user's stored hash
107    /// drifts forward as we ratchet the cost.
108    pub fn needs_rehash(&self, phc: &str) -> Result<bool> {
109        let parsed = PasswordHash::new(phc).map_err(map_phc_err)?;
110        // If the stored hash isn't argon2 at all, we should re-hash.
111        if parsed.algorithm.as_str() != Algorithm::Argon2id.ident().as_str() {
112            return Ok(true);
113        }
114        let stored = Params::try_from(&parsed).map_err(map_phc_err)?;
115        Ok(stored.m_cost() != self.params.m_cost()
116            || stored.t_cost() != self.params.t_cost()
117            || stored.p_cost() != self.params.p_cost())
118    }
119}
120
121fn map_phc_err(e: password_hash::Error) -> Error {
122    Error::Backend(anyhow::anyhow!("argon2: {e}"))
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn hash_and_verify_round_trip() {
131        let hasher = PasswordHasher::default();
132        let phc = hasher.hash("hunter2").unwrap();
133        assert!(phc.starts_with("$argon2id$"));
134        assert!(hasher.verify("hunter2", &phc).unwrap());
135    }
136
137    #[test]
138    fn wrong_password_returns_ok_false() {
139        let hasher = PasswordHasher::default();
140        let phc = hasher.hash("correct").unwrap();
141        assert!(!hasher.verify("incorrect", &phc).unwrap());
142    }
143
144    #[test]
145    fn malformed_hash_returns_err() {
146        let hasher = PasswordHasher::default();
147        let result = hasher.verify("anything", "not-a-phc-string");
148        assert!(matches!(result, Err(Error::Backend(_))));
149    }
150
151    #[test]
152    fn salt_is_per_call_so_two_hashes_of_same_input_differ() {
153        let hasher = PasswordHasher::default();
154        let a = hasher.hash("same").unwrap();
155        let b = hasher.hash("same").unwrap();
156        assert_ne!(a, b);
157    }
158
159    #[test]
160    fn needs_rehash_false_for_same_params() {
161        let hasher = PasswordHasher::default();
162        let phc = hasher.hash("pw").unwrap();
163        assert!(!hasher.needs_rehash(&phc).unwrap());
164    }
165
166    #[test]
167    fn needs_rehash_true_when_params_drift() {
168        let weak = PasswordHasher::with_params(Params::new(8, 1, 1, None).unwrap());
169        let phc = weak.hash("pw").unwrap();
170        let strong = PasswordHasher::default();
171        assert!(strong.needs_rehash(&phc).unwrap());
172    }
173}