Skip to main content

webx_host/
password_argon2.rs

1//! Argon2id [`IPasswordHasher`] implementation — the framework default.
2//!
3//! Enabled by the `password-argon2` feature (on by default). `HostBuilder`
4//! registers one automatically in DI, so a handler only injects
5//! `Arc<dyn IPasswordHasher>`; register a different implementation — or this one
6//! with different [`Argon2Params`] — to replace/configure it.
7//!
8//! Both operations are CPU-bound and run on `tokio::task::spawn_blocking`
9//! threads, so a login never stalls an async worker.
10//!
11//! ```ignore
12//! // configure (e.g. raise the cost for a new deployment)
13//! let hasher = Argon2PasswordHasher::with_params(Argon2Params::new(65536, 3, 1));
14//! // or hash/verify through the port
15//! let phc = hasher.hash(&password).await?;
16//! if hasher.verify(&password, &stored).await? && hasher.needs_rehash(&stored) { /* upgrade */ }
17//! ```
18
19use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
20use argon2::{Algorithm, Argon2, Params, Version};
21use rand_core::OsRng;
22use webx_core::error::{Error, Result};
23use webx_core::security::IPasswordHasher;
24
25/// Algorithm identifier embedded in the PHC string produced by this hasher.
26const ARGON2ID_IDENT: &str = "argon2id";
27
28/// Argon2 cost parameters.
29///
30/// The defaults mirror `argon2::Argon2::default()` (19 MiB memory, 2 iterations,
31/// 1 lane — the OWASP baseline), so hashes created by hand-written
32/// `Argon2::default()` code keep verifying after a migration to this port.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct Argon2Params {
35    /// Memory cost in KiB (`m`).
36    pub memory_kib: u32,
37    /// Iterations / time cost (`t`).
38    pub iterations: u32,
39    /// Lanes / parallelism (`p`).
40    pub parallelism: u32,
41}
42
43impl Argon2Params {
44    /// Build a parameter set. Values are validated when hashing; invalid
45    /// combinations surface as a 500 [`Error::Internal`] instead of panicking.
46    pub const fn new(memory_kib: u32, iterations: u32, parallelism: u32) -> Self {
47        Self {
48            memory_kib,
49            iterations,
50            parallelism,
51        }
52    }
53
54    /// The `argon2` crate defaults (19 MiB / 2 / 1).
55    pub const fn owasp_default() -> Self {
56        Self::new(19 * 1024, 2, 1)
57    }
58
59    /// Convert to the `argon2` crate's own parameter type.
60    fn to_argon2_params(self) -> Result<Params> {
61        Params::new(self.memory_kib, self.iterations, self.parallelism, None).map_err(|error| {
62            Error::Internal(format!(
63                "invalid Argon2 parameters (m={}, t={}, p={}): {error}",
64                self.memory_kib, self.iterations, self.parallelism
65            ))
66        })
67    }
68}
69
70impl Default for Argon2Params {
71    fn default() -> Self {
72        Self::owasp_default()
73    }
74}
75
76/// Argon2id password hasher.
77///
78/// Uses Argon2id (v0x13), a fresh random salt per hash, and PHC output, so the
79/// algorithm and cost parameters are stored with each hash.
80#[derive(Debug, Clone, Copy, Default)]
81pub struct Argon2PasswordHasher {
82    params: Argon2Params,
83}
84
85impl Argon2PasswordHasher {
86    /// The framework default (Argon2id with [`Argon2Params::owasp_default`]).
87    pub fn new() -> Self {
88        Self::default()
89    }
90
91    /// Use explicit cost parameters (also used for `needs_rehash` decisions).
92    pub fn with_params(params: Argon2Params) -> Self {
93        Self { params }
94    }
95
96    /// The parameters this hasher produces hashes with.
97    pub fn params(&self) -> Argon2Params {
98        self.params
99    }
100}
101
102/// Parse and fully verify one PHC string against `password` — runs on a blocking
103/// thread.
104fn verify_blocking(password: &str, stored_hash: &str) -> Result<bool> {
105    let parsed = PasswordHash::new(stored_hash)
106        .map_err(|error| Error::Internal(format!("stored password hash is malformed: {error}")))?;
107
108    // A PHC string without salt or output cannot represent a password hash; the
109    // PHC parser accepts the shape, so verify the fields exists before treating a
110    // parse as a usable stored hash.
111    if parsed.salt.is_none() || parsed.hash.is_none() {
112        return Err(Error::Internal(
113            "stored password hash is malformed: missing salt or hash output".to_string(),
114        ));
115    }
116    // Reject unusable parameter fields (e.g. `m=notanumber`) explicitly: a broken
117    // stored record must surface as an error, not as "wrong password".
118    Params::try_from(&parsed).map_err(|error| {
119        Error::Internal(format!(
120            "stored password hash parameters are invalid: {error}"
121        ))
122    })?;
123
124    // `verify_password` reads the algorithm's parameters from the PHC string, so
125    // the hasher's own configured cost does not prevent older hashes verifying.
126    let algorithm = match parsed.algorithm.as_str() {
127        ARGON2ID_IDENT => Algorithm::Argon2id,
128        "argon2i" => Algorithm::Argon2i,
129        "argon2d" => Algorithm::Argon2d,
130        other => {
131            return Err(Error::Internal(format!(
132                "stored password hash uses an unsupported algorithm '{other}'"
133            )))
134        }
135    };
136    let argon2 = Argon2::new(algorithm, Version::V0x13, Params::DEFAULT);
137
138    match argon2.verify_password(password.as_bytes(), &parsed) {
139        Ok(()) => Ok(true),
140        // A mismatch is an ordinary authentication failure, not corruption.
141        Err(argon2::password_hash::Error::Password) => Ok(false),
142        Err(error) => Err(Error::Internal(format!(
143            "password verification failed: {error}"
144        ))),
145    }
146}
147
148#[async_trait::async_trait]
149impl IPasswordHasher for Argon2PasswordHasher {
150    async fn hash(&self, password: &str) -> Result<String> {
151        let params = self.params;
152        let password = password.to_owned();
153        // Dry-run the parameter validation before spawning: a bad configuration
154        // must fail fast with a typed error, not inside a join handle.
155        params.to_argon2_params()?;
156
157        tokio::task::spawn_blocking(move || {
158            let argon2 = Argon2::new(
159                Algorithm::Argon2id,
160                Version::V0x13,
161                params.to_argon2_params()?,
162            );
163            let salt = SaltString::generate(&mut OsRng);
164            argon2
165                .hash_password(password.as_bytes(), &salt)
166                .map(|hash| hash.to_string())
167                .map_err(|error| Error::Internal(format!("password hashing failed: {error}")))
168        })
169        .await
170        .map_err(|error| Error::Internal(format!("password hashing task failed: {error}")))?
171    }
172
173    async fn verify(&self, password: &str, stored_hash: &str) -> Result<bool> {
174        let password = password.to_owned();
175        let stored_hash = stored_hash.to_owned();
176        tokio::task::spawn_blocking(move || verify_blocking(&password, &stored_hash))
177            .await
178            .map_err(|error| {
179                Error::Internal(format!("password verification task failed: {error}"))
180            })?
181    }
182
183    fn needs_rehash(&self, stored_hash: &str) -> bool {
184        let Ok(parsed) = PasswordHash::new(stored_hash) else {
185            // Unparseable: cannot be trusted, upgrade it on the next login.
186            return true;
187        };
188        if parsed.salt.is_none()
189            || parsed.hash.is_none()
190            || parsed.algorithm.as_str() != ARGON2ID_IDENT
191            || parsed.version != Some(0x13)
192        {
193            // 0x13 is Argon2 version v=19 (`Version::V0x13`); the PHC field is a
194            // plain decimal, not the enum.
195            return true;
196        }
197        let Ok(stored) = Params::try_from(&parsed) else {
198            return true;
199        };
200        stored.m_cost() != self.params.memory_kib
201            || stored.t_cost() != self.params.iterations
202            || stored.p_cost() != self.params.parallelism
203    }
204}