hayson-engine 0.25.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
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;
/// What a stored envelope may ask of the machine verifying it: a crafted value must not be able to
/// make `verify_password` allocate gigabytes or loop for minutes.
const MAX_MEMORY_KIB: u32 = 1 << 20;
const MAX_ITERATIONS: u32 = 64;
const MAX_PARALLELISM: u32 = 16;

/// Argon2id cost of the hashes a handle writes. The default is the OWASP minimum profile
/// (19 MiB, 2 passes, one lane); a weak device may lower it, a server may raise it. A stored hash
/// keeps the parameters it was made with, so changing these never invalidates one — it only makes
/// [`PasswordCheck::needs_rehash`] true for the older ones.
#[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}")))
    }
}

/// The answer of [`verify_password`]: whether the candidate matches, and whether the stored hash was
/// made with other parameters than the handle's current ones (so a successful login is a good
/// moment to write the password again).
#[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)
}

/// The names of the fields a schema marks `passwordHash`, sorted.
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(&params).is_err() {
        return None;
    }
    Some(Parsed { params, salt, hash })
}

/// Whether `value` is a well-formed `v1` password envelope this build can verify against.
pub fn is_password_envelope(value: &Value) -> bool {
    value.as_str().is_some_and(|text| parse_envelope(text).is_some())
}

/// Hashes `plain` into `$hayson-pw$v1$argon2id$m=…,t=…,p=…$<salt>$<hash>` with a fresh random salt.
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
}

/// Checks `candidate` against a stored envelope. Nothing stored (no record, no field, `null`, or a
/// value that is not a well-formed envelope) still pays for a hash with the current parameters, so
/// the time taken does not tell an unknown account from a wrong password.
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,
    }
}

/// Replaces the plaintext of every `passwordHash` field with its envelope, leaving `null` and a value
/// that already is a well-formed envelope as they are — so a record read back and written again
/// (a `patch`, a copy, a compaction) keeps its hash instead of hashing the hash.
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());
    }
}