/* 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 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ //! 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, 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 ); }