Skip to main content

crypt_io/kdf/
argon2_impl.rs

1//! Argon2id backend (RFC 9106).
2//!
3//! Argon2id is the modern password-hashing standard — memory-hard,
4//! tuneable for time / memory / parallelism cost, and resistant to
5//! GPU / FPGA brute-force at sensible parameters. It is the right
6//! tool for hashing *passwords* (low-entropy inputs); for high-entropy
7//! material use [`crate::kdf::hkdf_sha256`] instead.
8//!
9//! The wrapper:
10//!
11//! - Generates a fresh salt via `mod_rand::tier3::fill_bytes` (OS
12//!   CSPRNG) on every [`argon2_hash`] call. The salt is encoded into
13//!   the returned PHC string; callers do not need to manage it.
14//! - Returns the standard PHC-encoded hash string
15//!   (`$argon2id$v=19$m=...,t=...,p=...$salt$hash`) which is
16//!   self-describing and accepted by every Argon2 implementation in
17//!   the ecosystem.
18//! - Defaults to the OWASP-recommended parameter set for sensitive
19//!   web-facing password hashing (~100 ms on a modern CPU).
20//!
21//! [PHC string format]: https://github.com/P-H-C/phc-string-format/blob/master/phc-sf-spec.md
22
23use alloc::string::{String, ToString};
24
25use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
26use argon2::{Algorithm, Argon2, Params, Version};
27
28use crate::error::{Error, Result};
29
30/// Default Argon2id output length, in bytes. Equal to `32` (256 bits).
31pub const ARGON2_DEFAULT_OUTPUT_LEN: usize = 32;
32
33/// Default Argon2id salt length, in bytes. Equal to `16` (128 bits, the
34/// PHC-recommended minimum).
35pub const ARGON2_DEFAULT_SALT_LEN: usize = 16;
36
37/// Tuneable Argon2id parameters.
38///
39/// Construct via [`Argon2Params::default`] (OWASP-recommended, ~100 ms
40/// on a modern CPU) or via [`Argon2Params::new`] for custom values.
41///
42/// - `m_cost`: memory cost in kibibytes (1 unit = 1024 bytes).
43/// - `t_cost`: number of iterations (time cost).
44/// - `p_cost`: parallelism / lanes.
45/// - `output_len`: derived-key length in bytes; defaults to 32.
46///
47/// Reducing any parameter reduces resistance to brute-force; the
48/// defaults are tuned for "human authentication" (login flows). For
49/// machine-to-machine credentials a higher memory/time cost is
50/// appropriate.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct Argon2Params {
53    /// Memory cost in kibibytes.
54    pub m_cost: u32,
55    /// Time cost (iterations).
56    pub t_cost: u32,
57    /// Parallelism (number of lanes).
58    pub p_cost: u32,
59    /// Derived-key length in bytes.
60    pub output_len: usize,
61}
62
63impl Argon2Params {
64    /// Construct a custom parameter set.
65    #[must_use]
66    pub const fn new(m_cost: u32, t_cost: u32, p_cost: u32, output_len: usize) -> Self {
67        Self {
68            m_cost,
69            t_cost,
70            p_cost,
71            output_len,
72        }
73    }
74}
75
76impl Default for Argon2Params {
77    /// OWASP-recommended defaults for sensitive web-facing password
78    /// hashing: 19 MiB memory, 2 iterations, 1 lane, 32-byte output.
79    /// Yields roughly 100 ms per hash on a modern CPU.
80    fn default() -> Self {
81        Self {
82            m_cost: 19 * 1024,
83            t_cost: 2,
84            p_cost: 1,
85            output_len: ARGON2_DEFAULT_OUTPUT_LEN,
86        }
87    }
88}
89
90/// Hash `password` with Argon2id using the default parameter set and a
91/// fresh random salt. Returns the PHC-encoded hash string.
92///
93/// The salt is generated via `mod_rand::tier3::fill_bytes` (OS CSPRNG)
94/// and embedded in the returned string, so callers do not need to
95/// manage salt storage separately.
96///
97/// # Errors
98///
99/// Returns [`Error::RandomFailure`] if the OS RNG cannot produce a
100/// salt, or [`Error::Kdf`] if the Argon2 implementation rejects the
101/// (default) parameters or fails to hash.
102///
103/// # Example
104///
105/// ```no_run
106/// # #[cfg(feature = "kdf-argon2")] {
107/// use crypt_io::kdf;
108/// let phc = kdf::argon2_hash(b"correct horse battery staple")?;
109/// assert!(phc.starts_with("$argon2id$"));
110/// # }
111/// # Ok::<(), crypt_io::Error>(())
112/// ```
113pub fn argon2_hash(password: &[u8]) -> Result<String> {
114    argon2_hash_with_params(password, Argon2Params::default())
115}
116
117/// Like [`argon2_hash`] but uses caller-supplied parameters.
118///
119/// # Errors
120///
121/// Same as [`argon2_hash`].
122pub fn argon2_hash_with_params(password: &[u8], params: Argon2Params) -> Result<String> {
123    let mut salt_bytes = [0u8; ARGON2_DEFAULT_SALT_LEN];
124    mod_rand::tier3::fill_bytes(&mut salt_bytes)
125        .map_err(|_| Error::RandomFailure("mod_rand::tier3::fill_bytes"))?;
126
127    let salt =
128        SaltString::encode_b64(&salt_bytes).map_err(|_| Error::Kdf("argon2 salt encoding"))?;
129
130    let argon2_params = Params::new(
131        params.m_cost,
132        params.t_cost,
133        params.p_cost,
134        Some(params.output_len),
135    )
136    .map_err(|_| Error::Kdf("argon2 invalid params"))?;
137    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon2_params);
138
139    let hash = argon2
140        .hash_password(password, &salt)
141        .map_err(|_| Error::Kdf("argon2 hash"))?;
142    Ok(hash.to_string())
143}
144
145/// Verify `password` against a PHC-encoded Argon2 hash string.
146///
147/// Returns `Ok(true)` if the password matches, `Ok(false)` if it does
148/// not, and [`Error::Kdf`] if `phc` is not a parseable Argon2 PHC
149/// string.
150///
151/// Argon2id's verification re-derives the hash under the encoded
152/// parameters and compares in constant time. The cost is the same as
153/// computing a fresh hash with those parameters — usually ~100 ms with
154/// the default params.
155///
156/// # Errors
157///
158/// Returns [`Error::Kdf`] only when `phc` fails to parse as a valid
159/// PHC string. A correctly-formatted but wrong-password hash returns
160/// `Ok(false)`.
161///
162/// # Example
163///
164/// ```no_run
165/// # #[cfg(feature = "kdf-argon2")] {
166/// use crypt_io::kdf;
167/// let phc = kdf::argon2_hash(b"hunter2")?;
168/// assert!(kdf::argon2_verify(&phc, b"hunter2")?);
169/// assert!(!kdf::argon2_verify(&phc, b"hunter3")?);
170/// # }
171/// # Ok::<(), crypt_io::Error>(())
172/// ```
173pub fn argon2_verify(phc: &str, password: &[u8]) -> Result<bool> {
174    let parsed = PasswordHash::new(phc).map_err(|_| Error::Kdf("argon2 phc parse"))?;
175    let argon2 = Argon2::default();
176    Ok(argon2.verify_password(password, &parsed).is_ok())
177}
178
179#[cfg(test)]
180#[allow(clippy::unwrap_used, clippy::expect_used, unused_results)]
181mod tests {
182    use super::*;
183    use alloc::format;
184
185    // Reduced parameters for tests so we don't burn 100 ms per case.
186    // The functional contract (round-trip, wrong-password rejection,
187    // tampered-hash rejection, parse-failure surfacing) is identical;
188    // only the runtime cost changes.
189    fn fast_params() -> Argon2Params {
190        Argon2Params {
191            m_cost: 8,
192            t_cost: 1,
193            p_cost: 1,
194            output_len: 32,
195        }
196    }
197
198    #[test]
199    fn hash_then_verify_round_trip() {
200        let phc = argon2_hash_with_params(b"hunter2", fast_params()).unwrap();
201        assert!(phc.starts_with("$argon2id$"));
202        assert!(argon2_verify(&phc, b"hunter2").unwrap());
203    }
204
205    #[test]
206    fn verify_rejects_wrong_password() {
207        let phc = argon2_hash_with_params(b"correct", fast_params()).unwrap();
208        assert!(!argon2_verify(&phc, b"wrong").unwrap());
209    }
210
211    #[test]
212    fn two_hashes_of_same_password_differ() {
213        // Different salts → different PHC strings, even for identical
214        // password + params. Verifies salt randomisation is wired up.
215        let p = fast_params();
216        let a = argon2_hash_with_params(b"same", p).unwrap();
217        let b = argon2_hash_with_params(b"same", p).unwrap();
218        assert_ne!(a, b);
219        assert!(argon2_verify(&a, b"same").unwrap());
220        assert!(argon2_verify(&b, b"same").unwrap());
221    }
222
223    #[test]
224    fn verify_rejects_unparseable_phc() {
225        let err = argon2_verify("not-a-valid-phc-string", b"password").unwrap_err();
226        assert!(matches!(err, Error::Kdf(_)), "{err:?}");
227    }
228
229    #[test]
230    fn verify_rejects_tampered_phc() {
231        let phc = argon2_hash_with_params(b"hunter2", fast_params()).unwrap();
232        // Flip the last character of the hash portion (after the final $).
233        let mut chars: alloc::vec::Vec<char> = phc.chars().collect();
234        let last = chars.len() - 1;
235        chars[last] = if chars[last] == 'A' { 'B' } else { 'A' };
236        let tampered: String = chars.into_iter().collect();
237        // Verifies false (correct PHC structure, wrong hash) — not an
238        // error.
239        assert!(!argon2_verify(&tampered, b"hunter2").unwrap());
240    }
241
242    #[test]
243    fn empty_password_round_trips() {
244        // Edge case: empty password should hash and verify cleanly.
245        let phc = argon2_hash_with_params(b"", fast_params()).unwrap();
246        assert!(argon2_verify(&phc, b"").unwrap());
247        assert!(!argon2_verify(&phc, b"not-empty").unwrap());
248    }
249
250    #[test]
251    fn long_password_round_trips() {
252        let password = [b'x'; 1024];
253        let phc = argon2_hash_with_params(&password, fast_params()).unwrap();
254        assert!(argon2_verify(&phc, &password).unwrap());
255    }
256
257    #[test]
258    fn custom_params_are_honoured() {
259        // Encode params into the PHC string and check they round-trip.
260        let params = Argon2Params::new(16, 2, 1, 32);
261        let phc = argon2_hash_with_params(b"pw", params).unwrap();
262        // PHC encodes as `$argon2id$v=19$m=16,t=2,p=1$...$...`.
263        assert!(phc.contains("m=16"));
264        assert!(phc.contains("t=2"));
265        assert!(phc.contains("p=1"));
266    }
267
268    #[test]
269    fn default_params_use_owasp_recommendations() {
270        let d = Argon2Params::default();
271        assert_eq!(d.m_cost, 19 * 1024);
272        assert_eq!(d.t_cost, 2);
273        assert_eq!(d.p_cost, 1);
274        assert_eq!(d.output_len, ARGON2_DEFAULT_OUTPUT_LEN);
275    }
276
277    #[test]
278    fn invalid_params_rejected() {
279        // m_cost too small (Argon2 requires m_cost >= 8 * p_cost).
280        let bad = Argon2Params::new(0, 1, 1, 32);
281        let err = argon2_hash_with_params(b"pw", bad).unwrap_err();
282        assert!(matches!(err, Error::Kdf(_)), "{err:?}");
283    }
284
285    #[test]
286    fn error_messages_redact_password() {
287        // Defence-in-depth: ensure no Error variant rendering leaks a
288        // password byte even when we go through the failure paths.
289        let secret = "my-super-secret-password";
290        let err = argon2_verify("not-a-phc", secret.as_bytes()).unwrap_err();
291        let rendered = format!("{err}");
292        assert!(!rendered.contains(secret));
293        let rendered_dbg = format!("{err:?}");
294        assert!(!rendered_dbg.contains(secret));
295    }
296}