use argon2::{Algorithm, Argon2, Params, Version};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use rand::Rng;
use serde_json::Value;
use crate::errors::HaysonSchemaError;
use crate::schema::{HaysonRecord, TableSchema};
const PREFIX: &str = "$hayson-pw$v1$argon2id$";
const SALT_BYTES: usize = 16;
const HASH_BYTES: usize = 32;
const MAX_MEMORY_KIB: u32 = 1 << 20;
const MAX_ITERATIONS: u32 = 64;
const MAX_PARALLELISM: u32 = 16;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PasswordHashParams {
pub memory_kib: u32,
pub iterations: u32,
pub parallelism: u32,
}
impl Default for PasswordHashParams {
fn default() -> Self {
PasswordHashParams {
memory_kib: 19_456,
iterations: 2,
parallelism: 1,
}
}
}
impl PasswordHashParams {
pub fn validate(&self) -> Result<(), HaysonSchemaError> {
argon_params(self)
.map(|_| ())
.map_err(|reason| HaysonSchemaError(format!("password hash parameters are not usable: {reason}")))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PasswordCheck {
pub ok: bool,
pub needs_rehash: bool,
}
fn argon_params(params: &PasswordHashParams) -> Result<Params, String> {
if params.memory_kib > MAX_MEMORY_KIB || params.iterations > MAX_ITERATIONS || params.parallelism > MAX_PARALLELISM
{
return Err("over the limits this engine accepts".to_string());
}
Params::new(
params.memory_kib,
params.iterations,
params.parallelism,
Some(HASH_BYTES),
)
.map_err(|error| error.to_string())
}
fn derive(candidate: &[u8], salt: &[u8], params: &PasswordHashParams) -> Result<[u8; HASH_BYTES], String> {
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, argon_params(params)?);
let mut out = [0u8; HASH_BYTES];
argon
.hash_password_into(candidate, salt, &mut out)
.map_err(|error| error.to_string())?;
Ok(out)
}
pub fn password_fields_of(schema: &TableSchema) -> Vec<String> {
let mut fields: Vec<String> = schema
.fields
.iter()
.filter(|(_, spec)| spec.password_hash)
.map(|(name, _)| name.clone())
.collect();
fields.sort();
fields
}
struct Parsed {
params: PasswordHashParams,
salt: Vec<u8>,
hash: Vec<u8>,
}
fn parse_envelope(text: &str) -> Option<Parsed> {
let rest = text.strip_prefix(PREFIX)?;
let mut parts = rest.split('$');
let (cost, salt, hash) = (parts.next()?, parts.next()?, parts.next()?);
if parts.next().is_some() {
return None;
}
let mut params = PasswordHashParams::default();
let mut seen = 0;
for item in cost.split(',') {
let (name, value) = item.split_once('=')?;
let value: u32 = value.parse().ok()?;
match name {
"m" => params.memory_kib = value,
"t" => params.iterations = value,
"p" => params.parallelism = value,
_ => return None,
}
seen += 1;
}
let salt = URL_SAFE_NO_PAD.decode(salt).ok()?;
let hash = URL_SAFE_NO_PAD.decode(hash).ok()?;
if seen != 3 || salt.len() < 8 || hash.len() != HASH_BYTES || argon_params(¶ms).is_err() {
return None;
}
Some(Parsed { params, salt, hash })
}
pub fn is_password_envelope(value: &Value) -> bool {
value.as_str().is_some_and(|text| parse_envelope(text).is_some())
}
pub fn hash_password(plain: &str, params: &PasswordHashParams) -> Result<String, HaysonSchemaError> {
let mut salt = [0u8; SALT_BYTES];
rand::rng().fill_bytes(&mut salt);
let hash = derive(plain.as_bytes(), &salt, params)
.map_err(|reason| HaysonSchemaError(format!("password could not be hashed: {reason}")))?;
Ok(format!(
"{PREFIX}m={},t={},p={}${}${}",
params.memory_kib,
params.iterations,
params.parallelism,
URL_SAFE_NO_PAD.encode(salt),
URL_SAFE_NO_PAD.encode(hash),
))
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut difference = 0u8;
for (x, y) in a.iter().zip(b) {
difference |= x ^ y;
}
std::hint::black_box(difference) == 0
}
pub fn verify_password(stored: Option<&Value>, candidate: &str, current: &PasswordHashParams) -> PasswordCheck {
let parsed = stored.and_then(Value::as_str).and_then(parse_envelope);
let Some(parsed) = parsed else {
let _ = derive(candidate.as_bytes(), &[0u8; SALT_BYTES], current);
return PasswordCheck {
ok: false,
needs_rehash: false,
};
};
let ok = derive(candidate.as_bytes(), &parsed.salt, &parsed.params)
.map(|derived| constant_time_eq(&derived, &parsed.hash))
.unwrap_or(false);
PasswordCheck {
ok,
needs_rehash: ok && parsed.params != *current,
}
}
pub fn hash_record(
record: &HaysonRecord,
fields: &[String],
params: &PasswordHashParams,
) -> Result<HaysonRecord, HaysonSchemaError> {
let mut hashed = record.clone();
for field in fields {
let Some(value) = hashed.get(field) else { continue };
if value.is_null() || is_password_envelope(value) {
continue;
}
let Some(plain) = value.as_str() else { continue };
let envelope = hash_password(plain, params)?;
hashed.insert(field.clone(), Value::String(envelope));
}
Ok(hashed)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn cheap() -> PasswordHashParams {
PasswordHashParams {
memory_kib: 64,
iterations: 1,
parallelism: 1,
}
}
#[test]
fn a_hash_verifies_the_right_password_and_refuses_a_wrong_one() {
let envelope = Value::String(hash_password("correct horse", &cheap()).unwrap());
assert!(verify_password(Some(&envelope), "correct horse", &cheap()).ok);
assert!(!verify_password(Some(&envelope), "correct horse!", &cheap()).ok);
assert!(envelope
.as_str()
.unwrap()
.starts_with("$hayson-pw$v1$argon2id$m=64,t=1,p=1$"));
}
#[test]
fn equal_passwords_get_different_salts_and_hashes() {
assert_ne!(
hash_password("same", &cheap()).unwrap(),
hash_password("same", &cheap()).unwrap()
);
}
#[test]
fn a_hash_made_with_other_parameters_still_verifies_and_asks_for_a_rehash() {
let old = Value::String(hash_password("pw", &cheap()).unwrap());
let now = PasswordHashParams {
memory_kib: 128,
..cheap()
};
let check = verify_password(Some(&old), "pw", &now);
assert!(check.ok && check.needs_rehash);
assert!(!verify_password(Some(&old), "pw", &cheap()).needs_rehash);
assert!(!verify_password(Some(&old), "nope", &now).needs_rehash);
}
#[test]
fn nothing_usable_stored_is_a_refusal_not_an_error() {
for stored in [
None,
Some(Value::Null),
Some(json!(5)),
Some(json!("plain")),
Some(json!("$hayson-pw$v1$argon2id$m=1")),
] {
assert_eq!(
verify_password(stored.as_ref(), "x", &cheap()),
PasswordCheck {
ok: false,
needs_rehash: false
}
);
}
}
#[test]
fn a_stored_envelope_cannot_demand_unreasonable_cost() {
let greedy = format!(
"{PREFIX}m=4194304,t=2,p=1${}${}",
URL_SAFE_NO_PAD.encode([1u8; 16]),
URL_SAFE_NO_PAD.encode([2u8; 32])
);
assert!(parse_envelope(&greedy).is_none());
}
#[test]
fn hash_record_hashes_plaintext_and_leaves_envelopes_and_null_alone() {
let envelope = hash_password("pw", &cheap()).unwrap();
let record: HaysonRecord = serde_json::from_value(json!({
"id": "u1", "secret": "pw", "kept": envelope, "empty": null
}))
.unwrap();
let fields = vec!["secret".to_string(), "kept".to_string(), "empty".to_string()];
let hashed = hash_record(&record, &fields, &cheap()).unwrap();
assert!(is_password_envelope(&hashed["secret"]));
assert_ne!(hashed["secret"], json!("pw"));
assert_eq!(hashed["kept"], json!(envelope));
assert_eq!(hashed["empty"], Value::Null);
}
#[test]
fn parameters_over_the_limits_are_not_usable() {
assert!(PasswordHashParams::default().validate().is_ok());
assert!(PasswordHashParams {
memory_kib: 1 << 30,
..cheap()
}
.validate()
.is_err());
assert!(PasswordHashParams {
iterations: 0,
..cheap()
}
.validate()
.is_err());
}
}