Skip to main content

rustlavel_auth/
hashing.rs

1//! Password hashing with argon2id.
2//!
3//! `hash_password` produces a PHC string — `$argon2id$v=19$m=...,t=...,p=...$salt$hash` —
4//! which carries the algorithm, the version, the cost and the salt alongside
5//! the digest. That is what lets [`verify_password`] keep working after the
6//! cost is raised, and what lets [`needs_rehash`] notice that an old hash is
7//! now under-provisioned.
8
9use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
10use argon2::{Algorithm, Argon2, Params, Version};
11use rustlavel_core::{Error, Result};
12
13/// How much work one password hash costs.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct Cost {
16    /// Memory used per hash, in kibibytes.
17    pub memory_kib: u32,
18    /// Passes over that memory.
19    pub iterations: u32,
20    /// Lanes hashed in parallel.
21    pub parallelism: u32,
22}
23
24impl Cost {
25    /// The default: 19 MiB of memory, two passes, one lane.
26    ///
27    /// This is OWASP's first recommended argon2id configuration. Memory is the
28    /// parameter that actually defends against attackers: a GPU or ASIC can
29    /// pipeline iterations cheaply but has to buy 19 MiB of fast memory per
30    /// guess, which is what collapses their parallelism advantage. Two passes
31    /// is the minimum argon2id allows at this memory size, and one lane keeps
32    /// the cost predictable on a busy web server that is already running one
33    /// request per core.
34    ///
35    /// On a modern server this lands around 40 ms per hash — slow enough to
36    /// make offline cracking expensive, fast enough that a login request does
37    /// not feel it.
38    pub const DEFAULT: Cost = Cost { memory_kib: 19_456, iterations: 2, parallelism: 1 };
39
40    /// The cheapest configuration argon2 accepts.
41    ///
42    /// Deliberately far too weak for real passwords. It exists because a test
43    /// suite that hashes a dozen passwords at the real cost spends its whole
44    /// runtime inside argon2, and a slow suite is a suite that stops being run.
45    /// Never use this to store a password.
46    pub const FAST: Cost = Cost { memory_kib: 8, iterations: 1, parallelism: 1 };
47
48    fn params(self) -> Result<Params> {
49        Params::new(self.memory_kib, self.iterations, self.parallelism, None)
50            .map_err(|e| Error::msg(format!("invalid argon2 parameters: {e}")))
51    }
52}
53
54impl Default for Cost {
55    fn default() -> Self {
56        Cost::DEFAULT
57    }
58}
59
60/// The SHA-256 of some bytes, as lowercase hex.
61///
62/// For high-entropy secrets only — an emailed link, a lookup key — never for a
63/// password. A password needs a slow hash with a salt, which is
64/// [`hash_password`]; this is the opposite, and using it on one would be a
65/// serious mistake.
66///
67/// The reasoning runs the other way too. A 32-byte random token has nothing to
68/// guess, so slowing the check down protects nobody, and a password-reset
69/// endpoint that spent a quarter of a second on argon2 per attempt would hand
70/// anybody a denial-of-service lever.
71pub fn sha256_hex(bytes: &[u8]) -> String {
72    use sha2::{Digest, Sha256};
73    Sha256::digest(bytes).iter().map(|byte| format!("{byte:02x}")).collect()
74}
75
76/// Hash a password at the default cost.
77pub fn hash_password(password: &str) -> Result<String> {
78    hash_password_with(password, Cost::DEFAULT)
79}
80
81/// Hash a password at an explicit cost.
82///
83/// Every hash gets its own 16 bytes of salt from the OS CSPRNG, so two users
84/// who chose the same password still get different digests and a precomputed
85/// table buys an attacker nothing.
86pub fn hash_password_with(password: &str, cost: Cost) -> Result<String> {
87    let salt = SaltString::encode_b64(&crate::random::bytes(16))
88        .map_err(|e| Error::msg(format!("could not encode a password salt: {e}")))?;
89
90    let hasher = Argon2::new(Algorithm::Argon2id, Version::V0x13, cost.params()?);
91    let hash = hasher
92        .hash_password(password.as_bytes(), &salt)
93        .map_err(|e| Error::msg(format!("could not hash the password: {e}")))?;
94
95    Ok(hash.to_string())
96}
97
98/// Check a password against a stored hash.
99///
100/// Constant-time by construction: argon2 re-derives the digest using the
101/// parameters recorded in `hash` and compares the two with a branch-free
102/// equality check, so a wrong password takes the same time whether it differs
103/// in the first byte or the last. A malformed or unrecognised hash is simply
104/// `false` — an unparseable value in the database must never authenticate
105/// anybody.
106pub fn verify_password(password: &str, hash: &str) -> bool {
107    let Ok(parsed) = PasswordHash::new(hash) else {
108        return false;
109    };
110
111    Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
112}
113
114/// Whether a stored hash was produced below the cost we now want.
115///
116/// Call it after a successful login: the plaintext password is in hand exactly
117/// once per session, which is the only moment an old hash can be upgraded.
118pub fn needs_rehash(hash: &str, cost: Cost) -> bool {
119    let Ok(parsed) = PasswordHash::new(hash) else {
120        // Unreadable, or hashed by some other algorithm entirely: rehash it.
121        return true;
122    };
123    if parsed.algorithm != Algorithm::Argon2id.ident() {
124        return true;
125    }
126
127    match Params::try_from(&parsed) {
128        Ok(params) => {
129            params.m_cost() < cost.memory_kib
130                || params.t_cost() < cost.iterations
131                || params.p_cost() != cost.parallelism
132        }
133        Err(_) => true,
134    }
135}
136
137#[cfg(test)]
138mod tests {
139    #[test]
140    fn sha256_matches_the_published_vectors() {
141        assert_eq!(
142            sha256_hex(b""),
143            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
144        );
145        assert_eq!(
146            sha256_hex(b"abc"),
147            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
148        );
149    }
150    use super::*;
151
152    #[test]
153    fn a_hashed_password_verifies_against_itself() {
154        let hash = hash_password_with("correct horse battery staple", Cost::FAST).unwrap();
155
156        assert!(verify_password("correct horse battery staple", &hash));
157    }
158
159    #[test]
160    fn the_wrong_password_is_rejected() {
161        let hash = hash_password_with("s3cret", Cost::FAST).unwrap();
162
163        assert!(!verify_password("s3cre", &hash));
164        assert!(!verify_password("s3cret ", &hash));
165        assert!(!verify_password("", &hash));
166        assert!(!verify_password("S3cret", &hash));
167    }
168
169    #[test]
170    fn the_same_password_hashes_differently_every_time() {
171        let first = hash_password_with("repeated", Cost::FAST).unwrap();
172        let second = hash_password_with("repeated", Cost::FAST).unwrap();
173
174        assert_ne!(first, second, "each hash must carry its own salt");
175        assert!(verify_password("repeated", &first));
176        assert!(verify_password("repeated", &second));
177    }
178
179    #[test]
180    fn hashes_are_phc_strings_naming_argon2id() {
181        let hash = hash_password_with("x", Cost::FAST).unwrap();
182
183        assert!(hash.starts_with("$argon2id$v=19$"), "unexpected hash format: {hash}");
184        assert!(hash.contains("m=8,t=1,p=1"), "cost should be recorded in the hash: {hash}");
185    }
186
187    #[test]
188    fn a_corrupt_or_foreign_hash_never_authenticates() {
189        assert!(!verify_password("anything", ""));
190        assert!(!verify_password("anything", "not-a-hash"));
191        assert!(!verify_password("anything", "$argon2id$v=19$m=8,t=1,p=1$c2FsdA$"));
192        // A bcrypt hash from some other system must not be treated as a match.
193        assert!(!verify_password("anything", "$2y$10$abcdefghijklmnopqrstuv"));
194    }
195
196    #[test]
197    fn a_hash_verifies_even_though_the_default_cost_is_higher() {
198        // Verification uses the parameters stored in the hash, not the current
199        // defaults, so raising the cost does not lock existing users out.
200        let cheap = hash_password_with("legacy", Cost::FAST).unwrap();
201
202        assert!(verify_password("legacy", &cheap));
203        assert!(needs_rehash(&cheap, Cost::DEFAULT));
204        assert!(!needs_rehash(&cheap, Cost::FAST));
205    }
206
207    #[test]
208    fn unreadable_hashes_are_reported_as_needing_a_rehash() {
209        assert!(needs_rehash("", Cost::DEFAULT));
210        assert!(needs_rehash("$2y$10$abcdefghijklmnopqrstuv", Cost::DEFAULT));
211    }
212
213    #[test]
214    fn the_default_cost_produces_a_working_hash() {
215        // The one test that pays the real cost, so the shipped parameters are
216        // proven to be accepted by argon2 rather than merely plausible.
217        let hash = hash_password("default cost").unwrap();
218
219        assert!(verify_password("default cost", &hash));
220        assert!(!verify_password("wrong", &hash));
221        assert!(!needs_rehash(&hash, Cost::DEFAULT));
222    }
223}