Skip to main content

hayson_engine/crypto/
password.rs

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;
13/// What a stored envelope may ask of the machine verifying it: a crafted value must not be able to
14/// make `verify_password` allocate gigabytes or loop for minutes.
15const MAX_MEMORY_KIB: u32 = 1 << 20;
16const MAX_ITERATIONS: u32 = 64;
17const MAX_PARALLELISM: u32 = 16;
18
19/// Argon2id cost of the hashes a handle writes. The default is the OWASP minimum profile
20/// (19 MiB, 2 passes, one lane); a weak device may lower it, a server may raise it. A stored hash
21/// keeps the parameters it was made with, so changing these never invalidates one — it only makes
22/// [`PasswordCheck::needs_rehash`] true for the older ones.
23#[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/// The answer of [`verify_password`]: whether the candidate matches, and whether the stored hash was
49/// made with other parameters than the handle's current ones (so a successful login is a good
50/// moment to write the password again).
51#[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
80/// The names of the fields a schema marks `passwordHash`, sorted.
81pub 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(&params).is_err() {
121        return None;
122    }
123    Some(Parsed { params, salt, hash })
124}
125
126/// Whether `value` is a well-formed `v1` password envelope this build can verify against.
127pub fn is_password_envelope(value: &Value) -> bool {
128    value.as_str().is_some_and(|text| parse_envelope(text).is_some())
129}
130
131/// Hashes `plain` into `$hayson-pw$v1$argon2id$m=…,t=…,p=…$<salt>$<hash>` with a fresh random salt.
132pub 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
158/// Checks `candidate` against a stored envelope. Nothing stored (no record, no field, `null`, or a
159/// value that is not a well-formed envelope) still pays for a hash with the current parameters, so
160/// the time taken does not tell an unknown account from a wrong password.
161pub 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
179/// Replaces the plaintext of every `passwordHash` field with its envelope, leaving `null` and a value
180/// that already is a well-formed envelope as they are — so a record read back and written again
181/// (a `patch`, a copy, a compaction) keeps its hash instead of hashing the hash.
182pub 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}