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 // Tamper a character in the **middle of the salt** portion.
233 // The salt sits between the second-to-last and the last '$'.
234 // A mid-base64 char is always a full-6-bit character — any
235 // letter swap stays in the alphabet and keeps the structure
236 // valid. We avoid the end of the hash portion: its trailing
237 // char encodes fractional bits and strict base64 decoders
238 // (e.g. `base64ct`) reject letters that introduce non-zero
239 // padding bits.
240 let last_dollar = phc.rfind('$').expect("PHC has final $");
241 let phc_prefix = &phc[..last_dollar];
242 let salt_start = phc_prefix.rfind('$').expect("PHC has salt-leading $") + 1;
243 let salt_middle = salt_start + (last_dollar - salt_start) / 2;
244 let mut bytes = phc.into_bytes();
245 let original = bytes[salt_middle];
246 bytes[salt_middle] = if original == b'A' { b'B' } else { b'A' };
247 let tampered = String::from_utf8(bytes).expect("still valid utf-8");
248 // Tampered salt → different derived hash → Ok(false), and
249 // crucially NOT a parse error. This is the contract we want
250 // to lock in: structurally-valid PHCs with the wrong hash
251 // return Ok(false), not Err(Kdf).
252 assert!(!argon2_verify(&tampered, b"hunter2").unwrap());
253 }
254
255 #[test]
256 fn empty_password_round_trips() {
257 // Edge case: empty password should hash and verify cleanly.
258 let phc = argon2_hash_with_params(b"", fast_params()).unwrap();
259 assert!(argon2_verify(&phc, b"").unwrap());
260 assert!(!argon2_verify(&phc, b"not-empty").unwrap());
261 }
262
263 #[test]
264 fn long_password_round_trips() {
265 let password = [b'x'; 1024];
266 let phc = argon2_hash_with_params(&password, fast_params()).unwrap();
267 assert!(argon2_verify(&phc, &password).unwrap());
268 }
269
270 #[test]
271 fn custom_params_are_honoured() {
272 // Encode params into the PHC string and check they round-trip.
273 let params = Argon2Params::new(16, 2, 1, 32);
274 let phc = argon2_hash_with_params(b"pw", params).unwrap();
275 // PHC encodes as `$argon2id$v=19$m=16,t=2,p=1$...$...`.
276 assert!(phc.contains("m=16"));
277 assert!(phc.contains("t=2"));
278 assert!(phc.contains("p=1"));
279 }
280
281 #[test]
282 fn default_params_use_owasp_recommendations() {
283 let d = Argon2Params::default();
284 assert_eq!(d.m_cost, 19 * 1024);
285 assert_eq!(d.t_cost, 2);
286 assert_eq!(d.p_cost, 1);
287 assert_eq!(d.output_len, ARGON2_DEFAULT_OUTPUT_LEN);
288 }
289
290 #[test]
291 fn invalid_params_rejected() {
292 // m_cost too small (Argon2 requires m_cost >= 8 * p_cost).
293 let bad = Argon2Params::new(0, 1, 1, 32);
294 let err = argon2_hash_with_params(b"pw", bad).unwrap_err();
295 assert!(matches!(err, Error::Kdf(_)), "{err:?}");
296 }
297
298 #[test]
299 fn error_messages_redact_password() {
300 // Defence-in-depth: ensure no Error variant rendering leaks a
301 // password byte even when we go through the failure paths.
302 let secret = "my-super-secret-password";
303 let err = argon2_verify("not-a-phc", secret.as_bytes()).unwrap_err();
304 let rendered = format!("{err}");
305 assert!(!rendered.contains(secret));
306 let rendered_dbg = format!("{err:?}");
307 assert!(!rendered_dbg.contains(secret));
308 }
309}