1use argon2::{Algorithm, Argon2, Params, Version};
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use base64::Engine;
4use rand::Rng;
5use serde_json::Value;
6
7use crate::errors::HaysonSchemaError;
8use crate::schema::{HaysonRecord, TableSchema};
9
10const PREFIX: &str = "$hayson-pw$v1$argon2id$";
11const SALT_BYTES: usize = 16;
12const HASH_BYTES: usize = 32;
13const MAX_MEMORY_KIB: u32 = 1 << 20;
16const MAX_ITERATIONS: u32 = 64;
17const MAX_PARALLELISM: u32 = 16;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct PasswordHashParams {
25 pub memory_kib: u32,
26 pub iterations: u32,
27 pub parallelism: u32,
28}
29
30impl Default for PasswordHashParams {
31 fn default() -> Self {
32 PasswordHashParams {
33 memory_kib: 19_456,
34 iterations: 2,
35 parallelism: 1,
36 }
37 }
38}
39
40impl PasswordHashParams {
41 pub fn validate(&self) -> Result<(), HaysonSchemaError> {
42 argon_params(self)
43 .map(|_| ())
44 .map_err(|reason| HaysonSchemaError(format!("password hash parameters are not usable: {reason}")))
45 }
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub struct PasswordCheck {
53 pub ok: bool,
54 pub needs_rehash: bool,
55}
56
57fn argon_params(params: &PasswordHashParams) -> Result<Params, String> {
58 if params.memory_kib > MAX_MEMORY_KIB || params.iterations > MAX_ITERATIONS || params.parallelism > MAX_PARALLELISM
59 {
60 return Err("over the limits this engine accepts".to_string());
61 }
62 Params::new(
63 params.memory_kib,
64 params.iterations,
65 params.parallelism,
66 Some(HASH_BYTES),
67 )
68 .map_err(|error| error.to_string())
69}
70
71fn derive(candidate: &[u8], salt: &[u8], params: &PasswordHashParams) -> Result<[u8; HASH_BYTES], String> {
72 let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon_params(params)?);
73 let mut out = [0u8; HASH_BYTES];
74 argon
75 .hash_password_into(candidate, salt, &mut out)
76 .map_err(|error| error.to_string())?;
77 Ok(out)
78}
79
80pub fn password_fields_of(schema: &TableSchema) -> Vec<String> {
82 let mut fields: Vec<String> = schema
83 .fields
84 .iter()
85 .filter(|(_, spec)| spec.password_hash)
86 .map(|(name, _)| name.clone())
87 .collect();
88 fields.sort();
89 fields
90}
91
92struct Parsed {
93 params: PasswordHashParams,
94 salt: Vec<u8>,
95 hash: Vec<u8>,
96}
97
98fn parse_envelope(text: &str) -> Option<Parsed> {
99 let rest = text.strip_prefix(PREFIX)?;
100 let mut parts = rest.split('$');
101 let (cost, salt, hash) = (parts.next()?, parts.next()?, parts.next()?);
102 if parts.next().is_some() {
103 return None;
104 }
105 let mut params = PasswordHashParams::default();
106 let mut seen = 0;
107 for item in cost.split(',') {
108 let (name, value) = item.split_once('=')?;
109 let value: u32 = value.parse().ok()?;
110 match name {
111 "m" => params.memory_kib = value,
112 "t" => params.iterations = value,
113 "p" => params.parallelism = value,
114 _ => return None,
115 }
116 seen += 1;
117 }
118 let salt = URL_SAFE_NO_PAD.decode(salt).ok()?;
119 let hash = URL_SAFE_NO_PAD.decode(hash).ok()?;
120 if seen != 3 || salt.len() < 8 || hash.len() != HASH_BYTES || argon_params(¶ms).is_err() {
121 return None;
122 }
123 Some(Parsed { params, salt, hash })
124}
125
126pub fn is_password_envelope(value: &Value) -> bool {
128 value.as_str().is_some_and(|text| parse_envelope(text).is_some())
129}
130
131pub fn hash_password(plain: &str, params: &PasswordHashParams) -> Result<String, HaysonSchemaError> {
133 let mut salt = [0u8; SALT_BYTES];
134 rand::rng().fill_bytes(&mut salt);
135 let hash = derive(plain.as_bytes(), &salt, params)
136 .map_err(|reason| HaysonSchemaError(format!("password could not be hashed: {reason}")))?;
137 Ok(format!(
138 "{PREFIX}m={},t={},p={}${}${}",
139 params.memory_kib,
140 params.iterations,
141 params.parallelism,
142 URL_SAFE_NO_PAD.encode(salt),
143 URL_SAFE_NO_PAD.encode(hash),
144 ))
145}
146
147fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
148 if a.len() != b.len() {
149 return false;
150 }
151 let mut difference = 0u8;
152 for (x, y) in a.iter().zip(b) {
153 difference |= x ^ y;
154 }
155 std::hint::black_box(difference) == 0
156}
157
158pub fn verify_password(stored: Option<&Value>, candidate: &str, current: &PasswordHashParams) -> PasswordCheck {
162 let parsed = stored.and_then(Value::as_str).and_then(parse_envelope);
163 let Some(parsed) = parsed else {
164 let _ = derive(candidate.as_bytes(), &[0u8; SALT_BYTES], current);
165 return PasswordCheck {
166 ok: false,
167 needs_rehash: false,
168 };
169 };
170 let ok = derive(candidate.as_bytes(), &parsed.salt, &parsed.params)
171 .map(|derived| constant_time_eq(&derived, &parsed.hash))
172 .unwrap_or(false);
173 PasswordCheck {
174 ok,
175 needs_rehash: ok && parsed.params != *current,
176 }
177}
178
179pub fn hash_record(
183 record: &HaysonRecord,
184 fields: &[String],
185 params: &PasswordHashParams,
186) -> Result<HaysonRecord, HaysonSchemaError> {
187 let mut hashed = record.clone();
188 for field in fields {
189 let Some(value) = hashed.get(field) else { continue };
190 if value.is_null() || is_password_envelope(value) {
191 continue;
192 }
193 let Some(plain) = value.as_str() else { continue };
194 let envelope = hash_password(plain, params)?;
195 hashed.insert(field.clone(), Value::String(envelope));
196 }
197 Ok(hashed)
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use serde_json::json;
204
205 fn cheap() -> PasswordHashParams {
206 PasswordHashParams {
207 memory_kib: 64,
208 iterations: 1,
209 parallelism: 1,
210 }
211 }
212
213 #[test]
214 fn a_hash_verifies_the_right_password_and_refuses_a_wrong_one() {
215 let envelope = Value::String(hash_password("correct horse", &cheap()).unwrap());
216
217 assert!(verify_password(Some(&envelope), "correct horse", &cheap()).ok);
218 assert!(!verify_password(Some(&envelope), "correct horse!", &cheap()).ok);
219 assert!(envelope
220 .as_str()
221 .unwrap()
222 .starts_with("$hayson-pw$v1$argon2id$m=64,t=1,p=1$"));
223 }
224
225 #[test]
226 fn equal_passwords_get_different_salts_and_hashes() {
227 assert_ne!(
228 hash_password("same", &cheap()).unwrap(),
229 hash_password("same", &cheap()).unwrap()
230 );
231 }
232
233 #[test]
234 fn a_hash_made_with_other_parameters_still_verifies_and_asks_for_a_rehash() {
235 let old = Value::String(hash_password("pw", &cheap()).unwrap());
236 let now = PasswordHashParams {
237 memory_kib: 128,
238 ..cheap()
239 };
240
241 let check = verify_password(Some(&old), "pw", &now);
242
243 assert!(check.ok && check.needs_rehash);
244 assert!(!verify_password(Some(&old), "pw", &cheap()).needs_rehash);
245 assert!(!verify_password(Some(&old), "nope", &now).needs_rehash);
246 }
247
248 #[test]
249 fn nothing_usable_stored_is_a_refusal_not_an_error() {
250 for stored in [
251 None,
252 Some(Value::Null),
253 Some(json!(5)),
254 Some(json!("plain")),
255 Some(json!("$hayson-pw$v1$argon2id$m=1")),
256 ] {
257 assert_eq!(
258 verify_password(stored.as_ref(), "x", &cheap()),
259 PasswordCheck {
260 ok: false,
261 needs_rehash: false
262 }
263 );
264 }
265 }
266
267 #[test]
268 fn a_stored_envelope_cannot_demand_unreasonable_cost() {
269 let greedy = format!(
270 "{PREFIX}m=4194304,t=2,p=1${}${}",
271 URL_SAFE_NO_PAD.encode([1u8; 16]),
272 URL_SAFE_NO_PAD.encode([2u8; 32])
273 );
274 assert!(parse_envelope(&greedy).is_none());
275 }
276
277 #[test]
278 fn hash_record_hashes_plaintext_and_leaves_envelopes_and_null_alone() {
279 let envelope = hash_password("pw", &cheap()).unwrap();
280 let record: HaysonRecord = serde_json::from_value(json!({
281 "id": "u1", "secret": "pw", "kept": envelope, "empty": null
282 }))
283 .unwrap();
284 let fields = vec!["secret".to_string(), "kept".to_string(), "empty".to_string()];
285
286 let hashed = hash_record(&record, &fields, &cheap()).unwrap();
287
288 assert!(is_password_envelope(&hashed["secret"]));
289 assert_ne!(hashed["secret"], json!("pw"));
290 assert_eq!(hashed["kept"], json!(envelope));
291 assert_eq!(hashed["empty"], Value::Null);
292 }
293
294 #[test]
295 fn parameters_over_the_limits_are_not_usable() {
296 assert!(PasswordHashParams::default().validate().is_ok());
297 assert!(PasswordHashParams {
298 memory_kib: 1 << 30,
299 ..cheap()
300 }
301 .validate()
302 .is_err());
303 assert!(PasswordHashParams {
304 iterations: 0,
305 ..cheap()
306 }
307 .validate()
308 .is_err());
309 }
310}