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
);
}