hayson-engine 0.27.0

Storage engine for hayson document databases — the embeddable core behind hayson-server, hayson-cli and hayson-browser
Documentation
//! `passwordHash` fields from outside the crate: stored as an Argon2id envelope, verified without
//! being read back, kept across patches and reopens, and combinable with `cypher`.

use hayson_engine::crypto::{CipherOptions, PasswordHashParams};
use hayson_engine::database::{Database, OpenOptions};
use serde_json::{json, Value};
use tempfile::TempDir;

fn cheap() -> PasswordHashParams {
    PasswordHashParams {
        memory_kib: 64,
        iterations: 1,
        parallelism: 1,
    }
}

fn schema(extra: Value) -> Value {
    let mut password = json!({ "type": "string", "passwordHash": true });
    if let (Some(target), Some(more)) = (password.as_object_mut(), extra.as_object()) {
        target.extend(more.clone());
    }
    json!({
        "schemaVersion": 1,
        "table": "accounts",
        "primaryKey": "id",
        "fields": { "id": { "type": "string" }, "name": { "type": "string" }, "password": password },
    })
}

fn open(dir: &std::path::Path, params: PasswordHashParams, cypher: Option<CipherOptions>) -> Database {
    Database::open_with(
        dir,
        OpenOptions {
            password_hash: params,
            cypher,
            ..Default::default()
        },
    )
    .unwrap()
}

fn log_of(dir: &std::path::Path) -> String {
    std::fs::read_to_string(dir.join("accounts.jsonl")).unwrap()
}

#[test]
fn a_password_is_stored_as_a_hash_and_verifies_after_a_reopen() {
    let dir = TempDir::new().unwrap();
    let mut db = open(dir.path(), cheap(), None);
    db.create_table("accounts", schema(json!({}))).unwrap();
    db.table_mut("accounts")
        .unwrap()
        .put(
            &json!({ "id": "a1", "name": "Ann", "password": "hunter2 hunter2" }),
            false,
        )
        .unwrap();
    db.close().unwrap();

    let log = log_of(dir.path());
    assert!(!log.contains("hunter2"), "the plaintext reached the log");
    assert!(log.contains("$hayson-pw$v1$argon2id$m=64,t=1,p=1$"));

    let db = open(dir.path(), cheap(), None);
    let table = db.table("accounts").unwrap();
    assert!(table.verify_password("a1", "password", "hunter2 hunter2").unwrap().ok);
    assert!(!table.verify_password("a1", "password", "hunter3").unwrap().ok);
    assert!(!table.verify_password("nobody", "password", "x").unwrap().ok);
    assert!(
        table.verify_password("a1", "name", "Ann").is_err(),
        "name is not a password field"
    );
    let stored = table.get("a1").unwrap().unwrap();
    assert!(stored["password"].as_str().unwrap().starts_with("$hayson-pw$"));
}

#[test]
fn a_patch_of_another_field_keeps_the_hash_and_a_patch_of_the_password_replaces_it() {
    let dir = TempDir::new().unwrap();
    let mut db = open(dir.path(), cheap(), None);
    db.create_table("accounts", schema(json!({}))).unwrap();
    let table = db.table_mut("accounts").unwrap();
    table
        .put(&json!({ "id": "a1", "name": "Ann", "password": "first" }), false)
        .unwrap();
    let before = table.get("a1").unwrap().unwrap()["password"].clone();

    table.patch("a1", &json!({ "name": "Anna" }), false).unwrap();
    assert_eq!(
        table.get("a1").unwrap().unwrap()["password"],
        before,
        "the hash was re-hashed"
    );
    assert!(table.verify_password("a1", "password", "first").unwrap().ok);

    table.patch("a1", &json!({ "password": "second" }), false).unwrap();
    assert!(table.verify_password("a1", "password", "second").unwrap().ok);
    assert!(!table.verify_password("a1", "password", "first").unwrap().ok);
}

#[test]
fn a_hash_made_with_other_parameters_verifies_and_asks_for_a_rehash() {
    let dir = TempDir::new().unwrap();
    let mut db = open(dir.path(), cheap(), None);
    db.create_table("accounts", schema(json!({}))).unwrap();
    db.table_mut("accounts")
        .unwrap()
        .put(&json!({ "id": "a1", "password": "pw" }), false)
        .unwrap();
    db.close().unwrap();

    let stronger = PasswordHashParams {
        memory_kib: 128,
        ..cheap()
    };
    let mut db = open(dir.path(), stronger, None);
    let table = db.table_mut("accounts").unwrap();
    let check = table.verify_password("a1", "password", "pw").unwrap();
    assert!(check.ok && check.needs_rehash);

    table.patch("a1", &json!({ "password": "pw" }), false).unwrap();
    let again = table.verify_password("a1", "password", "pw").unwrap();
    assert!(again.ok && !again.needs_rehash);
    assert!(log_of(dir.path()).contains("m=128,t=1,p=1"));
}

#[test]
fn a_password_field_cannot_be_indexed_unique_defaulted_or_a_key() {
    let dir = TempDir::new().unwrap();
    let mut db = open(dir.path(), cheap(), None);
    for extra in [
        json!({ "index": "exact" }),
        json!({ "unique": true }),
        json!({ "default": "x" }),
    ] {
        assert!(db.create_table("accounts", schema(extra)).is_err());
    }
    let mut bad = schema(json!({}));
    bad["fields"]["password"]["type"] = json!("int");
    assert!(db.create_table("accounts", bad).is_err());
}

#[test]
fn the_field_may_also_be_encrypted_at_rest() {
    let dir = TempDir::new().unwrap();
    let cypher = || CipherOptions {
        keys: [("k1".to_string(), vec![7u8; 32])].into(),
        key_id: Some("k1".to_string()),
        on_missing_key: None,
    };
    let mut db = open(dir.path(), cheap(), Some(cypher()));
    db.create_table("accounts", schema(json!({ "cypher": true }))).unwrap();
    db.table_mut("accounts")
        .unwrap()
        .put(&json!({ "id": "a1", "password": "pw" }), false)
        .unwrap();
    db.close().unwrap();

    let log = log_of(dir.path());
    assert!(log.contains("$cypher$v1$k1$"));
    assert!(
        !log.contains("hayson-pw"),
        "the hash envelope is only inside the ciphertext"
    );

    let db = open(dir.path(), cheap(), Some(cypher()));
    assert!(
        db.table("accounts")
            .unwrap()
            .verify_password("a1", "password", "pw")
            .unwrap()
            .ok
    );
}