// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! Argon2id password hashing and verification. //! //! Hashes are stored in PHC string format, which embeds the algorithm, version, //! and cost parameters — so a stored hash is self-describing and verification //! never needs the config that produced it. That matters when the operator tunes //! the cost parameters over time: old hashes keep verifying against the costs //! they were made with. use argon2::password_hash::rand_core::OsRng; use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use argon2::{Algorithm, Argon2, Params, Version}; use crate::AuthError; /// Argon2id cost parameters, mirroring the `[auth]` config keys. Kept as a plain /// value type so the `auth` crate need not depend on `config`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct HashParams { /// Memory cost, in KiB (`auth.argon2_m_cost`). pub m_cost: u32, /// Time cost — the number of iterations (`auth.argon2_t_cost`). pub t_cost: u32, /// Degree of parallelism — lanes (`auth.argon2_p_cost`). pub p_cost: u32, } impl Default for HashParams { /// The spec's baseline: 19 MiB, two iterations, one lane. fn default() -> Self { Self { m_cost: 19_456, t_cost: 2, p_cost: 1, } } } impl HashParams { /// Build the concrete Argon2 hasher for these parameters. fn hasher(self) -> Result, AuthError> { let params = Params::new(self.m_cost, self.t_cost, self.p_cost, None) .map_err(|e| AuthError::Hash(e.to_string()))?; Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params)) } } /// Hash `password` with Argon2id, returning a PHC string ready to store. /// /// A fresh random salt is drawn from the OS CSPRNG for every call, so identical /// passwords produce different hashes. /// /// # Errors /// /// Returns [`AuthError::Hash`] if the cost parameters are invalid or the hasher /// fails. pub fn hash_password(password: &str, params: HashParams) -> Result { let salt = SaltString::generate(&mut OsRng); let hash = params .hasher()? .hash_password(password.as_bytes(), &salt) .map_err(|e| AuthError::Hash(e.to_string()))?; Ok(hash.to_string()) } /// Verify `password` against a stored PHC hash. /// /// `stored` is [`Option`] because a user may have no password yet (the invite is /// still outstanding). In that case — and whenever the stored hash fails to parse /// — we still run a full Argon2 computation against a throwaway salt before /// returning `false`, so the "no such credential" path costs the same as a real /// verification and account state does not leak through response timing. /// /// Returns `true` only when the password matches a well-formed stored hash. #[must_use] pub fn verify_password(password: &str, stored: Option<&str>) -> bool { let Some(hash) = stored else { dummy_verify(password); return false; }; let Ok(parsed) = PasswordHash::new(hash) else { dummy_verify(password); return false; }; // Argon2id verification reads its parameters from the PHC string itself, so a // default-configured verifier honours whatever costs the hash was made with. Argon2::default() .verify_password(password.as_bytes(), &parsed) .is_ok() } /// Perform throwaway hashing work to equalise the timing of the failure paths in /// [`verify_password`]. The result is intentionally discarded. fn dummy_verify(password: &str) { let _ = hash_password(password, HashParams::default()); } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; // Cheap parameters keep the hashing tests fast; production costs come from // config. The minimum Argon2 memory cost is 8 KiB. const FAST: HashParams = HashParams { m_cost: 8, t_cost: 1, p_cost: 1, }; #[test] fn hash_is_phc_argon2id_and_verifies() { let hash = hash_password("correct horse", FAST).unwrap(); assert!(hash.starts_with("$argon2id$"), "PHC prefix, got {hash}"); assert!(verify_password("correct horse", Some(&hash))); assert!(!verify_password("wrong horse", Some(&hash))); } #[test] fn hashing_is_salted() { let a = hash_password("same", FAST).unwrap(); let b = hash_password("same", FAST).unwrap(); assert_ne!(a, b, "distinct salts should yield distinct hashes"); assert!(verify_password("same", Some(&a))); assert!(verify_password("same", Some(&b))); } #[test] fn absent_hash_never_verifies() { assert!(!verify_password("anything", None)); } #[test] fn malformed_hash_never_verifies() { assert!(!verify_password("anything", Some("not-a-phc-string"))); assert!(!verify_password("anything", Some("$argon2id$garbage"))); } #[test] fn params_round_trip_through_the_hash() { // A hash made with non-default costs still verifies with the default // verifier, because the costs travel inside the PHC string. let hash = hash_password("pw", FAST).unwrap(); assert!(hash.contains("m=8"), "params embedded, got {hash}"); assert!(verify_password("pw", Some(&hash))); } }