Source code
Revision control
Copy as Markdown
Other Tools
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
//! Tests for the multi-instance Password `KekType`.
//!
//! Each test mints its own Password kek_ref via the test-only
//! single-iteration helper so tests don't pay the ~100 ms PBKDF2 cost.
//! Production code always goes through `create_kek(KekType::Password,
//! ...)`, which uses `pbkdf2::PBKDF2_ITERATIONS` (800 000).
use lockstore_rs::{KekType, Keystore, LockstoreError};
use std::sync::Arc;
use std::thread::sleep;
use std::time::Duration;
use tempfile::tempdir;
const PW: &[u8] = b"correct horse battery staple";
const PW_WRONG: &[u8] = b"Tr0ub4dor&3";
const PW_NEW: &[u8] = b"gs5^&mR2!fb@1";
fn on_disk_keystore() -> (Arc<Keystore>, tempfile::TempDir) {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("lockstore.keys.sqlite");
let ks = Keystore::get(path).expect("new");
(ks, dir)
}
fn mint_local(ks: &Keystore) -> String {
ks.create_kek(KekType::LocalKey, "", b"", Duration::ZERO)
.expect("create local")
}
#[test]
fn create_password_kek_persists_record() {
let ks = Keystore::new_in_memory().expect("new");
let kek_ref = ks
.create_password_kek_test_only(PW)
.expect("create password");
assert!(kek_ref.starts_with("lockstore::kek::password:"));
// Newly-minted KEK starts locked: createKek doesn't open the cache.
assert!(!ks.is_kek_unlocked(&kek_ref).unwrap());
}
#[test]
fn unlock_then_get_dek_succeeds() {
let ks = Keystore::new_in_memory().expect("new");
let kek_ref = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&kek_ref, PW, Duration::from_secs(60))
.expect("unlock");
assert!(ks.is_kek_unlocked(&kek_ref).unwrap());
ks.create_dek("col", &kek_ref, true).expect("create_dek");
let (dek, _cs) = ks.get_dek("col", &kek_ref).expect("get_dek");
assert_eq!(dek.len(), 32);
}
#[test]
fn get_dek_when_locked_fails() {
let ks = Keystore::new_in_memory().expect("new");
let kek_ref = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&kek_ref, PW, Duration::from_secs(60))
.expect("unlock");
ks.create_dek("col", &kek_ref, true).expect("create_dek");
ks.lock_kek(&kek_ref).unwrap();
assert!(!ks.is_kek_unlocked(&kek_ref).unwrap());
let err = ks.get_dek("col", &kek_ref).unwrap_err();
assert!(matches!(err, LockstoreError::Locked), "got: {:?}", err);
}
#[test]
fn unlock_expires_after_timeout() {
let ks = Keystore::new_in_memory().expect("new");
let local = mint_local(&ks);
let pw = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.create_dek("col", &local, true).expect("create_dek");
ks.unlock_kek(&pw, PW, Duration::from_millis(100))
.expect("unlock");
ks.add_kek("col", &local, &pw).expect("add Password level");
sleep(Duration::from_millis(200));
assert!(!ks.is_kek_unlocked(&pw).unwrap());
let err = ks.get_dek("col", &pw).unwrap_err();
assert!(matches!(err, LockstoreError::Locked), "got: {:?}", err);
// LocalKey access still works after the Password kek expires.
ks.get_dek("col", &local).expect("local still ok");
}
#[test]
fn wrong_password_returns_wrong_password_and_does_not_cache() {
let ks = Keystore::new_in_memory().expect("new");
let kek_ref = ks
.create_password_kek_test_only(PW)
.expect("create password");
let err = ks
.unlock_kek(&kek_ref, PW_WRONG, Duration::from_secs(60))
.unwrap_err();
assert!(matches!(err, LockstoreError::WrongPassword));
assert!(!ks.is_kek_unlocked(&kek_ref).unwrap());
}
#[test]
fn unlock_unknown_kek_ref_reports_invalid_kek_ref() {
let ks = Keystore::new_in_memory().expect("new");
let err = ks
.unlock_kek(
"lockstore::kek::password:not-a-real-id",
PW,
Duration::from_secs(60),
)
.unwrap_err();
assert!(matches!(err, LockstoreError::InvalidKekRef(_)));
}
#[test]
fn two_independent_password_keks_unlock_independently() {
let ks = Keystore::new_in_memory().expect("new");
let kek_a = ks.create_password_kek_test_only(PW).expect("create A");
let kek_b = ks.create_password_kek_test_only(PW_NEW).expect("create B");
assert_ne!(kek_a, kek_b);
ks.unlock_kek(&kek_a, PW, Duration::from_secs(60))
.expect("unlock A");
assert!(ks.is_kek_unlocked(&kek_a).unwrap());
// B is unaffected by A's unlock.
assert!(!ks.is_kek_unlocked(&kek_b).unwrap());
ks.unlock_kek(&kek_b, PW_NEW, Duration::from_secs(60))
.expect("unlock B");
assert!(ks.is_kek_unlocked(&kek_b).unwrap());
// Wrong password against the other kek is rejected even when both
// are otherwise unlocked.
let err = ks
.unlock_kek(&kek_a, PW_NEW, Duration::from_secs(60))
.unwrap_err();
assert!(matches!(err, LockstoreError::WrongPassword));
// lock_kek only affects the named kek_ref.
ks.lock_kek(&kek_a).unwrap();
assert!(!ks.is_kek_unlocked(&kek_a).unwrap());
assert!(ks.is_kek_unlocked(&kek_b).unwrap());
}
#[test]
fn add_then_remove_local_level_leaves_password_only() {
let ks = Keystore::new_in_memory().expect("new");
let local = mint_local(&ks);
let pw = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&pw, PW, Duration::from_secs(60))
.expect("unlock");
ks.create_dek("c", &local, true).expect("create");
ks.add_kek("c", &local, &pw).expect("add");
ks.remove_kek("c", &local).expect("remove");
let err = ks.get_dek("c", &local).unwrap_err();
assert!(matches!(err, LockstoreError::NotFound(_)));
ks.get_dek("c", &pw).expect("password ok");
ks.lock_kek(&pw).unwrap();
let err = ks.get_dek("c", &pw).unwrap_err();
assert!(matches!(err, LockstoreError::Locked));
}
#[test]
fn reopen_on_disk_keystore_requires_unlock() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("lockstore.keys.sqlite");
let kek_ref;
{
let ks = Keystore::get(path.clone()).expect("new");
kek_ref = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&kek_ref, PW, Duration::from_secs(60))
.expect("unlock");
ks.create_dek("persisted", &kek_ref, true).expect("create");
ks.close();
}
let ks2 = Keystore::get(path).expect("reopen");
assert!(!ks2.is_kek_unlocked(&kek_ref).unwrap());
let err = ks2.get_dek("persisted", &kek_ref).unwrap_err();
assert!(matches!(err, LockstoreError::Locked));
ks2.unlock_kek(&kek_ref, PW, Duration::from_secs(60))
.expect("unlock reopened");
ks2.get_dek("persisted", &kek_ref)
.expect("get after reopen+unlock");
}
#[test]
fn close_locks_password() {
let (ks, _dir) = on_disk_keystore();
let kek_ref = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&kek_ref, PW, Duration::from_secs(3600))
.expect("unlock");
assert!(ks.is_kek_unlocked(&kek_ref).unwrap());
ks.close();
// A fresh keystore at the same path must not inherit the unlocked
// state (covered by `reopen_on_disk_keystore_requires_unlock`).
}
#[test]
fn encrypt_decrypt_roundtrip_local() {
let ks = Keystore::new_in_memory().expect("new");
let local = mint_local(&ks);
ks.create_dek("c", &local, false).expect("create");
let plaintext = b"hello, lockstore";
let blob = ks.encrypt("c", &local, plaintext).expect("encrypt");
assert_ne!(blob, plaintext);
let round = ks.decrypt("c", &local, &blob).expect("decrypt");
assert_eq!(round, plaintext);
}
#[test]
fn encrypt_decrypt_roundtrip_password() {
let ks = Keystore::new_in_memory().expect("new");
let kek_ref = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&kek_ref, PW, Duration::from_secs(60))
.expect("unlock");
ks.create_dek("c", &kek_ref, false).expect("create");
let plaintext = b"secret";
let blob = ks.encrypt("c", &kek_ref, plaintext).expect("encrypt");
let round = ks.decrypt("c", &kek_ref, &blob).expect("decrypt");
assert_eq!(round, plaintext);
ks.lock_kek(&kek_ref).unwrap();
let err = ks.encrypt("c", &kek_ref, plaintext).unwrap_err();
assert!(matches!(err, LockstoreError::Locked));
let err = ks.decrypt("c", &kek_ref, &blob).unwrap_err();
assert!(matches!(err, LockstoreError::Locked));
}
#[test]
fn encrypt_non_extractable_dek_still_works() {
let ks = Keystore::new_in_memory().expect("new");
let local = mint_local(&ks);
ks.create_dek("c", &local, false).expect("create");
// get_dek rejects because not extractable...
let err = ks.get_dek("c", &local).unwrap_err();
assert!(matches!(err, LockstoreError::NotExtractable(_)));
// ...but encrypt/decrypt bypass the extractability gate.
let blob = ks.encrypt("c", &local, b"abc").expect("encrypt");
let round = ks.decrypt("c", &local, &blob).expect("decrypt");
assert_eq!(round, b"abc");
}
#[test]
fn password_dek_supports_non_extractable() {
let ks = Keystore::new_in_memory().expect("new");
let kek_ref = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&kek_ref, PW, Duration::from_secs(60))
.expect("unlock");
ks.create_dek("nonex", &kek_ref, false)
.expect("create_dek non-extractable");
let err = ks.get_dek("nonex", &kek_ref).unwrap_err();
assert!(
matches!(err, LockstoreError::NotExtractable(_)),
"expected NotExtractable, got {:?}",
err
);
let pt = b"Password-bound payload";
let ct = ks
.encrypt("nonex", &kek_ref, pt)
.expect("encrypt under non-extractable Password DEK");
let pt2 = ks
.decrypt("nonex", &kek_ref, &ct)
.expect("decrypt under non-extractable Password DEK");
assert_eq!(pt2, pt);
}
#[test]
fn local_key_is_always_unlocked() {
let ks = Keystore::new_in_memory().expect("new");
let local = mint_local(&ks);
assert!(ks.is_kek_unlocked(&local).unwrap());
// lock / unlock are no-ops for LocalKey.
ks.lock_kek(&local).unwrap();
assert!(ks.is_kek_unlocked(&local).unwrap());
ks.unlock_kek(&local, b"", Duration::from_secs(1))
.expect("unlock no-op");
assert!(ks.is_kek_unlocked(&local).unwrap());
}
#[test]
fn malformed_kek_ref_reports_invalid_kek_ref() {
let ks = Keystore::new_in_memory().expect("new");
// Every entry point that parses a kek_ref surfaces a malformed one
// as `InvalidKekRef`: is_kek_unlocked, lock_kek, and unlock_kek.
assert!(matches!(
ks.is_kek_unlocked("lockstore::kek::bogus"),
Err(LockstoreError::InvalidKekRef(_))
));
assert!(matches!(
ks.lock_kek("lockstore::kek::bogus"),
Err(LockstoreError::InvalidKekRef(_))
));
let err = ks
.unlock_kek("lockstore::kek::bogus", b"x", Duration::from_secs(1))
.unwrap_err();
assert!(matches!(err, LockstoreError::InvalidKekRef(_)));
}
#[test]
fn lock_clears_password_cache() {
let ks = Keystore::new_in_memory().expect("new");
let kek_ref = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&kek_ref, PW, Duration::from_secs(60))
.expect("unlock");
assert!(ks.is_kek_unlocked(&kek_ref).unwrap());
ks.lock().unwrap();
assert!(!ks.is_kek_unlocked(&kek_ref).unwrap());
}
#[test]
fn remove_kek_leaves_password_record_intact() {
let ks = Keystore::new_in_memory().expect("new");
let local = mint_local(&ks);
let pw = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&pw, PW, Duration::from_secs(60))
.expect("unlock");
ks.create_dek("col", &pw, false).expect("create_dek");
ks.add_kek("col", &pw, &local).expect("add local");
ks.remove_kek("col", &pw).expect("remove password kek");
// remove_kek drops the wrapping only; the Password record persists
// on disk until the caller explicitly invokes delete_kek.
ks.unlock_kek(&pw, PW, Duration::from_secs(60))
.expect("password KEK still resolvable after remove_kek");
assert!(ks.is_kek_unlocked(&pw).unwrap());
}
#[test]
fn delete_dek_leaves_password_record_intact() {
let ks = Keystore::new_in_memory().expect("new");
let pw = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&pw, PW, Duration::from_secs(60))
.expect("unlock");
ks.create_dek("only", &pw, false).expect("create_dek");
ks.delete_dek("only").expect("delete_dek");
// delete_dek wipes the DEK metadata; the per-kek_ref Password
// record stays on disk and remains usable until the caller invokes
// delete_kek for it explicitly.
ks.unlock_kek(&pw, PW, Duration::from_secs(60))
.expect("password KEK still resolvable after delete_dek");
assert!(ks.is_kek_unlocked(&pw).unwrap());
}
// ---------------------------------------------------------------------------
// delete_kek
// ---------------------------------------------------------------------------
#[test]
fn delete_kek_drops_unreferenced_password_kek() {
let ks = Keystore::new_in_memory().expect("new");
let pw = ks
.create_password_kek_test_only(PW)
.expect("create password");
// Just-minted, no wrapping yet — delete must succeed and the
// record must be gone.
ks.delete_kek(&pw).expect("delete unreferenced kek");
let err = ks.unlock_kek(&pw, PW, Duration::from_secs(60)).unwrap_err();
assert!(
matches!(err, LockstoreError::InvalidKekRef(_)),
"expected InvalidKekRef after delete_kek, got {:?}",
err
);
}
#[test]
fn delete_kek_rejects_when_in_use() {
let ks = Keystore::new_in_memory().expect("new");
let pw = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&pw, PW, Duration::from_secs(60))
.expect("unlock");
ks.create_dek("col", &pw, false).expect("create_dek");
let err = ks.delete_kek(&pw).unwrap_err();
assert!(
matches!(err, LockstoreError::InvalidConfiguration(_)),
"expected InvalidConfiguration for in-use kek, got {:?}",
err
);
// Sanity: the record is still usable after the rejected delete.
let _ = ks.encrypt("col", &pw, b"still here").expect("encrypt");
}
#[test]
fn delete_kek_unknown_kek_ref_reports_not_found() {
let ks = Keystore::new_in_memory().expect("new");
let bogus = "lockstore::kek::password:not-a-real-id";
let err = ks.delete_kek(bogus).unwrap_err();
assert!(
matches!(err, LockstoreError::NotFound(_)),
"expected NotFound for missing kek_ref, got {:?}",
err
);
}
#[test]
fn delete_kek_invalid_kek_ref_format_reports_invalid_kek_ref() {
let ks = Keystore::new_in_memory().expect("new");
let err = ks.delete_kek("not-a-lockstore-kek-ref").unwrap_err();
assert!(
matches!(err, LockstoreError::InvalidKekRef(_)),
"expected InvalidKekRef for malformed kek_ref, got {:?}",
err
);
}
#[test]
fn delete_kek_drops_unreferenced_local_kek() {
let ks = Keystore::new_in_memory().expect("new");
let local = mint_local(&ks);
ks.delete_kek(&local)
.expect("delete unreferenced local kek");
// A subsequent encrypt under the deleted local kek must fail
// because the record is gone — get_kek_symkey returns NotFound.
ks.create_dek(
"col",
// Re-use a different local kek_ref so the create_dek itself
// succeeds; the assertion is that the deleted ref no longer
// resolves.
&mint_local(&ks),
false,
)
.expect("create_dek under a fresh local");
let err = ks.encrypt("col", &local, b"x").unwrap_err();
assert!(
matches!(err, LockstoreError::NotFound(_)),
"expected NotFound after delete_kek on local, got {:?}",
err
);
}
#[test]
fn delete_kek_after_switch_kek_succeeds() {
let ks = Keystore::new_in_memory().expect("new");
let pw = ks
.create_password_kek_test_only(PW)
.expect("create password");
ks.unlock_kek(&pw, PW, Duration::from_secs(60))
.expect("unlock");
ks.create_dek("col", &pw, false).expect("create_dek");
// Switch the collection's wrapping to a new password kek.
let pw2 = ks
.create_password_kek_test_only(PW_NEW)
.expect("create password 2");
ks.unlock_kek(&pw2, PW_NEW, Duration::from_secs(60))
.expect("unlock pw2");
ks.switch_kek("col", &pw, &pw2).expect("switch_kek");
// After switch_kek, `pw` no longer wraps any collection but its
// record is still on disk. delete_kek can drop it cleanly because
// no collection references it any more.
ks.delete_kek(&pw).expect("delete unreferenced pw");
// Subsequent unlock surfaces InvalidKekRef: the kek_ref parses,
// but no Password record exists at this id.
let err = ks.unlock_kek(&pw, PW, Duration::from_secs(60)).unwrap_err();
assert!(
matches!(err, LockstoreError::InvalidKekRef(_)),
"expected InvalidKekRef after delete_kek, got {:?}",
err
);
}