use kv_sync::{ signing::SoftwareSigner, test_server::{TestServer, SETUP_TOKEN}, }; use super::*; const PW: &str = "a long master password"; fn login_item(title: &str, password: &str) -> ItemDto { ItemDto { id: String::new(), kind: ItemKindDto::Login, title: title.into(), username: Some("me".into()), password: Some(password.into()), urls: vec!["https://example.com".into()], totp: None, notes: None, favorite: false, tags: vec![], modified_at: 0, } } struct TestSigner(SoftwareSigner); impl DeviceSigner for TestSigner { fn device_id(&self) -> String { kv_sync::DeviceSigner::device_id(&self.0).to_string() } fn public_key(&self) -> Vec { kv_sync::DeviceSigner::public_key(&self.0) } fn sign(&self, message: Vec) -> Result> { kv_sync::DeviceSigner::sign(&self.0, &message).map_err(internal) } } fn connect(core: &VaultCore, server: &TestServer) { core.configure_server(server.url(), vec![]).unwrap(); core.set_device_signer( Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))), "test".into(), PlatformDto::Macos, ) .unwrap(); } #[test] fn create_lock_unlock_crud() { let core = VaultCore::new_in_memory(); let sk = core.create_account(PW.into()).unwrap(); let id = core.save_item(login_item("Example", "s3cret")).unwrap(); core.lock(); assert!(matches!(core.list_items(), Err(CoreError::Locked))); assert!(matches!(core.unlock("wrong password!!".into(), sk.clone()), Err(CoreError::BadCredentials))); core.unlock(PW.into(), sk).unwrap(); assert_eq!(core.get_item(id.clone()).unwrap().password.as_deref(), Some("s3cret")); assert_eq!(core.items_for_url("https://login.example.com/x".into()).unwrap().len(), 1); assert!(core.items_for_url("https://example.org".into()).unwrap().is_empty()); // Edits keep history; deletes hide the item. let mut dto = core.get_item(id.clone()).unwrap(); dto.password = Some("n3w".into()); core.save_item(dto).unwrap(); assert_eq!(core.item_history(id.clone()).unwrap()[0].password.as_deref(), Some("s3cret")); core.delete_item(id.clone()).unwrap(); assert!(matches!(core.get_item(id.clone()), Err(CoreError::NotFound))); assert!(core.list_items().unwrap().is_empty()); assert!(matches!(core.delete_item(id), Err(CoreError::NotFound))); assert!(matches!(core.create_account(PW.into()), Err(CoreError::Invalid { .. }))); } #[test] fn persistent_vault_survives_reopen() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("vault.sqlite").to_string_lossy().into_owned(); let (sk, id, account) = { let core = VaultCore::open(path.clone()).unwrap(); assert!(!core.has_account()); let sk = core.create_account(PW.into()).unwrap(); let id = core.save_item(login_item("Bank", "pw")).unwrap(); (sk, id, core.account_id().unwrap()) }; let core = VaultCore::open(path.clone()).unwrap(); assert!(core.has_account() && core.is_locked()); assert_eq!(core.account_id().unwrap(), account); core.unlock(PW.into(), sk).unwrap(); assert_eq!(core.get_item(id).unwrap().title, "Bank"); // Only ciphertext is on disk. for f in [path.clone(), format!("{path}-wal")] { let raw = std::fs::read(&f).unwrap_or_default(); let hay = String::from_utf8_lossy(&raw); assert!(!hay.contains("Bank") && !hay.contains(PW), "{f}"); } } #[test] fn local_unlock_key() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("v.sqlite").to_string_lossy().into_owned(); let core = VaultCore::open(path.clone()).unwrap(); core.create_account(PW.into()).unwrap(); let id = core.save_item(login_item("Mail", "pw")).unwrap(); assert!(!core.has_local_unlock()); let luk = core.enable_local_unlock().unwrap(); assert_eq!(luk.len(), 32); assert!(core.has_local_unlock()); core.lock(); assert!(matches!(core.enable_local_unlock(), Err(CoreError::Locked))); assert!(matches!(core.unlock_with_local_key(vec![0; 32]), Err(CoreError::BadCredentials))); assert!(matches!(core.unlock_with_local_key(vec![0; 16]), Err(CoreError::Invalid { .. }))); core.unlock_with_local_key(luk.clone()).unwrap(); assert_eq!(core.get_item(id.clone()).unwrap().title, "Mail"); drop(core); // Persisted, and the AutoFill extension (another process) can use it. let core = VaultCore::open(path.clone()).unwrap(); assert!(core.has_local_unlock()); core.unlock_with_local_key(luk.clone()).unwrap(); assert_eq!(core.list_items().unwrap().len(), 1); // Re-enabling rotates the key. let luk2 = core.enable_local_unlock().unwrap(); assert_ne!(luk, luk2); core.lock(); assert!(core.unlock_with_local_key(luk).is_err()); core.unlock_with_local_key(luk2.clone()).unwrap(); core.disable_local_unlock().unwrap(); core.lock(); assert!(matches!(core.unlock_with_local_key(luk2), Err(CoreError::Invalid { .. }))); } #[tokio::test] async fn sync_needs_configuration() { let core = VaultCore::new_in_memory(); core.create_account(PW.into()).unwrap(); assert!(matches!(core.sync_now().await, Err(CoreError::NotConfigured))); assert!(core.configure_server("http://vault.example.com".into(), vec![]).is_err()); assert!(core.configure_server("https://vault.example.com".into(), vec!["not a pin".into()]).is_err()); core.configure_server("https://vault.example.com".into(), vec![]).unwrap(); assert!(matches!(core.sync_now().await, Err(CoreError::NotConfigured)), "no device signer yet"); } #[tokio::test] async fn two_devices_sync_through_server() { let server = TestServer::start().await; let dir = tempfile::tempdir().unwrap(); // Device A: create, register, add an item, sync. let a = VaultCore::open(dir.path().join("a.sqlite").to_string_lossy().into_owned()).unwrap(); let sk = a.create_account(PW.into()).unwrap(); connect(&a, &server); assert!(a.server_info().await.unwrap().setup_open); a.register_account(SETUP_TOKEN.into()).await.unwrap(); assert!(a.is_logged_in()); let id = a.save_item(login_item("Bank", "pw-a")).unwrap(); let r = a.sync_now().await.unwrap(); assert_eq!((r.pushed, r.vaults_synced), (1, 1)); // Device B: brand new, logs in with account id + password + Secret Key. let b_path = dir.path().join("b.sqlite").to_string_lossy().into_owned(); let b = VaultCore::open(b_path.clone()).unwrap(); connect(&b, &server); assert!(matches!(b.login("wrong password!!".into(), sk.clone(), None).await, Err(CoreError::BadCredentials))); assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok); assert!(b.has_account() && !b.is_locked()); b.sync_now().await.unwrap(); assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a")); assert_eq!(b.account_fingerprint().unwrap(), a.account_fingerprint().unwrap()); // Concurrent edits to different fields merge. let mut on_a = a.get_item(id.clone()).unwrap(); on_a.notes = Some("from A".into()); a.save_item(on_a).unwrap(); let mut on_b = b.get_item(id.clone()).unwrap(); on_b.username = Some("from-b".into()); b.save_item(on_b).unwrap(); a.sync_now().await.unwrap(); assert_eq!(b.sync_now().await.unwrap().merged, 1); a.sync_now().await.unwrap(); for core in [&a, &b] { let it = core.get_item(id.clone()).unwrap(); assert_eq!(it.notes.as_deref(), Some("from A")); assert_eq!(it.username.as_deref(), Some("from-b")); } // B enables biometrics, restarts, unlocks with the LUK; an expired session renews itself. let luk = b.enable_local_unlock().unwrap(); drop(b); let b = VaultCore::open(b_path).unwrap(); connect(&b, &server); b.unlock_with_local_key(luk).unwrap(); server.state().sessions.clear(); b.save_item(login_item("Second", "x")).unwrap(); assert_eq!(b.sync_now().await.unwrap().pushed, 1); a.sync_now().await.unwrap(); assert_eq!(a.list_items().unwrap().len(), 2); // Delete propagates. a.delete_item(id.clone()).unwrap(); a.sync_now().await.unwrap(); b.sync_now().await.unwrap(); assert!(matches!(b.get_item(id), Err(CoreError::NotFound))); b.logout().await.unwrap(); assert!(!b.is_logged_in()); } #[tokio::test] async fn integrity_alarm_reaches_the_app() { let server = TestServer::start().await; let a = VaultCore::new_in_memory(); a.create_account(PW.into()).unwrap(); connect(&a, &server); a.register_account(SETUP_TOKEN.into()).await.unwrap(); for i in 0..2 { a.save_item(login_item(&format!("item {i}"), "p")).unwrap(); a.sync_now().await.unwrap(); } let vid = *server.state().vaults.keys().next().unwrap(); let first = server.state().vaults[&vid].manifest_history[0].clone(); server.state().serve_manifest = Some(first); match a.sync_now().await { Err(CoreError::IntegrityAlarm { vault_id, reason }) => { assert_eq!(vault_id, vid.to_string()); assert!(reason.contains("rollback")); } other => panic!("expected integrity alarm, got {other:?}"), } // The vault stays readable locally. assert_eq!(a.list_items().unwrap().len(), 2); } #[tokio::test] async fn pending_approval_and_biometric_session_expiry() { let server = TestServer::start().await; let a = VaultCore::new_in_memory(); let sk = a.create_account(PW.into()).unwrap(); connect(&a, &server); a.register_account(SETUP_TOKEN.into()).await.unwrap(); a.sync_now().await.unwrap(); server.state().require_approval = true; let b = VaultCore::new_in_memory(); connect(&b, &server); assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval); assert!(!b.has_account()); // A approves B after comparing fingerprints, then B's next login succeeds. let devices = a.list_devices().await.unwrap(); let pending = devices.iter().find(|d| !d.approved).unwrap(); assert_eq!(pending.key_fingerprint, b.device_key_fingerprint().unwrap()); assert!(devices.iter().any(|d| d.current && d.approved)); a.approve_device(pending.device_id.clone()).await.unwrap(); assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok); b.sync_now().await.unwrap(); // Revoking B kills its session. a.revoke_device(pending.device_id.clone()).await.unwrap(); assert!(matches!(b.sync_now().await, Err(CoreError::NotAuthenticated | CoreError::PendingApproval))); // A biometric unlock without a stored OPAQUE input cannot renew the session: NotAuthenticated. let c = VaultCore::new_in_memory(); let sk_c = c.create_account(PW.into()).unwrap(); let luk = { // Simulate an old LUK record without auth input by unlocking via LUK after a fresh enable. c.lock(); c.unlock(PW.into(), sk_c).unwrap(); let luk = c.enable_local_unlock().unwrap(); let mut inner = c.lock_inner(); inner.account.as_mut().unwrap().local_unlock.as_mut().unwrap().auth_input = None; luk }; c.lock(); c.unlock_with_local_key(luk).unwrap(); connect(&c, &server); assert!(matches!(c.register_account("x".into()).await, Err(CoreError::NotAuthenticated))); } #[tokio::test] async fn save_item_bumps_seq_and_replays_are_refused() { let server = TestServer::start().await; let a = VaultCore::new_in_memory(); let sk = a.create_account(PW.into()).unwrap(); connect(&a, &server); a.register_account(SETUP_TOKEN.into()).await.unwrap(); let id = a.save_item(login_item("Bank", "pw1")).unwrap(); a.sync_now().await.unwrap(); let vid = *server.state().vaults.keys().next().unwrap(); let iid = Uuid::parse_str(&id).unwrap(); let old = server.state().vaults[&vid].items[&iid].clone(); let seq_of = |core: &VaultCore| { let (acct, u) = core.unlocked_snapshot().unwrap(); let li = core.store.get(acct.primary_vault, iid).unwrap().unwrap(); decrypt(&acct, &u.vault_key, &li.record).unwrap().unwrap().seq }; assert_eq!(seq_of(&a), 1); for pw in ["pw2", "pw3"] { let mut it = a.get_item(id.clone()).unwrap(); it.password = Some(pw.into()); a.save_item(it).unwrap(); } assert_eq!(seq_of(&a), 3); a.sync_now().await.unwrap(); let b = VaultCore::new_in_memory(); connect(&b, &server); b.login(PW.into(), sk, None).await.unwrap(); b.sync_now().await.unwrap(); assert_eq!(seq_of(&b), 3); server.state().write_item(vid, old); match b.sync_now().await { Err(CoreError::IntegrityAlarm { reason, .. }) => assert!(reason.contains("older version"), "{reason}"), other => panic!("expected integrity alarm, got {other:?}"), } assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw3")); }