fabrica

hanna/fabrica

5517 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Argon2id password hashing and verification.
6//!
7//! Hashes are stored in PHC string format, which embeds the algorithm, version,
8//! and cost parameters — so a stored hash is self-describing and verification
9//! never needs the config that produced it. That matters when the operator tunes
10//! the cost parameters over time: old hashes keep verifying against the costs
11//! they were made with.
12
13use argon2::password_hash::rand_core::OsRng;
14use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
15use argon2::{Algorithm, Argon2, Params, Version};
16
17use crate::AuthError;
18
19/// Argon2id cost parameters, mirroring the `[auth]` config keys. Kept as a plain
20/// value type so the `auth` crate need not depend on `config`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct HashParams {
23 /// Memory cost, in KiB (`auth.argon2_m_cost`).
24 pub m_cost: u32,
25 /// Time cost — the number of iterations (`auth.argon2_t_cost`).
26 pub t_cost: u32,
27 /// Degree of parallelism — lanes (`auth.argon2_p_cost`).
28 pub p_cost: u32,
29}
30
31impl Default for HashParams {
32 /// The spec's baseline: 19 MiB, two iterations, one lane.
33 fn default() -> Self {
34 Self {
35 m_cost: 19_456,
36 t_cost: 2,
37 p_cost: 1,
38 }
39 }
40}
41
42impl HashParams {
43 /// Build the concrete Argon2 hasher for these parameters.
44 fn hasher(self) -> Result<Argon2<'static>, AuthError> {
45 let params = Params::new(self.m_cost, self.t_cost, self.p_cost, None)
46 .map_err(|e| AuthError::Hash(e.to_string()))?;
47 Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
48 }
49}
50
51/// Hash `password` with Argon2id, returning a PHC string ready to store.
52///
53/// A fresh random salt is drawn from the OS CSPRNG for every call, so identical
54/// passwords produce different hashes.
55///
56/// # Errors
57///
58/// Returns [`AuthError::Hash`] if the cost parameters are invalid or the hasher
59/// fails.
60pub fn hash_password(password: &str, params: HashParams) -> Result<String, AuthError> {
61 let salt = SaltString::generate(&mut OsRng);
62 let hash = params
63 .hasher()?
64 .hash_password(password.as_bytes(), &salt)
65 .map_err(|e| AuthError::Hash(e.to_string()))?;
66 Ok(hash.to_string())
67}
68
69/// Verify `password` against a stored PHC hash.
70///
71/// `stored` is [`Option`] because a user may have no password yet (the invite is
72/// still outstanding). In that case — and whenever the stored hash fails to parse
73/// — we still run a full Argon2 computation against a throwaway salt before
74/// returning `false`, so the "no such credential" path costs the same as a real
75/// verification and account state does not leak through response timing.
76///
77/// Returns `true` only when the password matches a well-formed stored hash.
78#[must_use]
79pub fn verify_password(password: &str, stored: Option<&str>) -> bool {
80 let Some(hash) = stored else {
81 dummy_verify(password);
82 return false;
83 };
84 let Ok(parsed) = PasswordHash::new(hash) else {
85 dummy_verify(password);
86 return false;
87 };
88 // Argon2id verification reads its parameters from the PHC string itself, so a
89 // default-configured verifier honours whatever costs the hash was made with.
90 Argon2::default()
91 .verify_password(password.as_bytes(), &parsed)
92 .is_ok()
93}
94
95/// Perform throwaway hashing work to equalise the timing of the failure paths in
96/// [`verify_password`]. The result is intentionally discarded.
97fn dummy_verify(password: &str) {
98 let _ = hash_password(password, HashParams::default());
99}
100
101#[cfg(test)]
102mod tests {
103 #![allow(clippy::unwrap_used)]
104
105 use super::*;
106
107 // Cheap parameters keep the hashing tests fast; production costs come from
108 // config. The minimum Argon2 memory cost is 8 KiB.
109 const FAST: HashParams = HashParams {
110 m_cost: 8,
111 t_cost: 1,
112 p_cost: 1,
113 };
114
115 #[test]
116 fn hash_is_phc_argon2id_and_verifies() {
117 let hash = hash_password("correct horse", FAST).unwrap();
118 assert!(hash.starts_with("$argon2id$"), "PHC prefix, got {hash}");
119 assert!(verify_password("correct horse", Some(&hash)));
120 assert!(!verify_password("wrong horse", Some(&hash)));
121 }
122
123 #[test]
124 fn hashing_is_salted() {
125 let a = hash_password("same", FAST).unwrap();
126 let b = hash_password("same", FAST).unwrap();
127 assert_ne!(a, b, "distinct salts should yield distinct hashes");
128 assert!(verify_password("same", Some(&a)));
129 assert!(verify_password("same", Some(&b)));
130 }
131
132 #[test]
133 fn absent_hash_never_verifies() {
134 assert!(!verify_password("anything", None));
135 }
136
137 #[test]
138 fn malformed_hash_never_verifies() {
139 assert!(!verify_password("anything", Some("not-a-phc-string")));
140 assert!(!verify_password("anything", Some("$argon2id$garbage")));
141 }
142
143 #[test]
144 fn params_round_trip_through_the_hash() {
145 // A hash made with non-default costs still verifies with the default
146 // verifier, because the costs travel inside the PHC string.
147 let hash = hash_password("pw", FAST).unwrap();
148 assert!(hash.contains("m=8"), "params embedded, got {hash}");
149 assert!(verify_password("pw", Some(&hash)));
150 }
151}