rustlavel_auth/
hashing.rs1use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
10use argon2::{Algorithm, Argon2, Params, Version};
11use rustlavel_core::{Error, Result};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct Cost {
16 pub memory_kib: u32,
18 pub iterations: u32,
20 pub parallelism: u32,
22}
23
24impl Cost {
25 pub const DEFAULT: Cost = Cost { memory_kib: 19_456, iterations: 2, parallelism: 1 };
39
40 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
60pub fn hash_password(password: &str) -> Result<String> {
62 hash_password_with(password, Cost::DEFAULT)
63}
64
65pub fn hash_password_with(password: &str, cost: Cost) -> Result<String> {
71 let salt = SaltString::encode_b64(&crate::random::bytes(16))
72 .map_err(|e| Error::msg(format!("could not encode a password salt: {e}")))?;
73
74 let hasher = Argon2::new(Algorithm::Argon2id, Version::V0x13, cost.params()?);
75 let hash = hasher
76 .hash_password(password.as_bytes(), &salt)
77 .map_err(|e| Error::msg(format!("could not hash the password: {e}")))?;
78
79 Ok(hash.to_string())
80}
81
82pub fn verify_password(password: &str, hash: &str) -> bool {
91 let Ok(parsed) = PasswordHash::new(hash) else {
92 return false;
93 };
94
95 Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok()
96}
97
98pub fn needs_rehash(hash: &str, cost: Cost) -> bool {
103 let Ok(parsed) = PasswordHash::new(hash) else {
104 return true;
106 };
107 if parsed.algorithm != Algorithm::Argon2id.ident() {
108 return true;
109 }
110
111 match Params::try_from(&parsed) {
112 Ok(params) => {
113 params.m_cost() < cost.memory_kib
114 || params.t_cost() < cost.iterations
115 || params.p_cost() != cost.parallelism
116 }
117 Err(_) => true,
118 }
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 #[test]
126 fn a_hashed_password_verifies_against_itself() {
127 let hash = hash_password_with("correct horse battery staple", Cost::FAST).unwrap();
128
129 assert!(verify_password("correct horse battery staple", &hash));
130 }
131
132 #[test]
133 fn the_wrong_password_is_rejected() {
134 let hash = hash_password_with("s3cret", Cost::FAST).unwrap();
135
136 assert!(!verify_password("s3cre", &hash));
137 assert!(!verify_password("s3cret ", &hash));
138 assert!(!verify_password("", &hash));
139 assert!(!verify_password("S3cret", &hash));
140 }
141
142 #[test]
143 fn the_same_password_hashes_differently_every_time() {
144 let first = hash_password_with("repeated", Cost::FAST).unwrap();
145 let second = hash_password_with("repeated", Cost::FAST).unwrap();
146
147 assert_ne!(first, second, "each hash must carry its own salt");
148 assert!(verify_password("repeated", &first));
149 assert!(verify_password("repeated", &second));
150 }
151
152 #[test]
153 fn hashes_are_phc_strings_naming_argon2id() {
154 let hash = hash_password_with("x", Cost::FAST).unwrap();
155
156 assert!(hash.starts_with("$argon2id$v=19$"), "unexpected hash format: {hash}");
157 assert!(hash.contains("m=8,t=1,p=1"), "cost should be recorded in the hash: {hash}");
158 }
159
160 #[test]
161 fn a_corrupt_or_foreign_hash_never_authenticates() {
162 assert!(!verify_password("anything", ""));
163 assert!(!verify_password("anything", "not-a-hash"));
164 assert!(!verify_password("anything", "$argon2id$v=19$m=8,t=1,p=1$c2FsdA$"));
165 assert!(!verify_password("anything", "$2y$10$abcdefghijklmnopqrstuv"));
167 }
168
169 #[test]
170 fn a_hash_verifies_even_though_the_default_cost_is_higher() {
171 let cheap = hash_password_with("legacy", Cost::FAST).unwrap();
174
175 assert!(verify_password("legacy", &cheap));
176 assert!(needs_rehash(&cheap, Cost::DEFAULT));
177 assert!(!needs_rehash(&cheap, Cost::FAST));
178 }
179
180 #[test]
181 fn unreadable_hashes_are_reported_as_needing_a_rehash() {
182 assert!(needs_rehash("", Cost::DEFAULT));
183 assert!(needs_rehash("$2y$10$abcdefghijklmnopqrstuv", Cost::DEFAULT));
184 }
185
186 #[test]
187 fn the_default_cost_produces_a_working_hash() {
188 let hash = hash_password("default cost").unwrap();
191
192 assert!(verify_password("default cost", &hash));
193 assert!(!verify_password("wrong", &hash));
194 assert!(!needs_rehash(&hash, Cost::DEFAULT));
195 }
196}