diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index 524cd36..a4e7063 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -976,6 +976,27 @@ impl VaultCore { pub fn is_logged_in(&self) -> bool { self.store.get_meta(META_SESSION).ok().flatten().is_some() } + + /// Deletes the account (docs/03 "Deleting the account"; required by the App Store and Google Play): on the + /// sync server first when one is configured, then everything on this device. The master password is checked + /// again, so an unlocked but unattended device can't do it. A server that can't be reached fails the whole + /// call and nothing is deleted. Afterwards the app removes its Keychain/Keystore items (Secret Key, local + /// unlock key) and shows onboarding; this device's key stays and is enrolled again by the next setup. + pub async fn delete_account(&self, master_password: String) -> Result<()> { + self.verify_master_password(&master_password)?; + if self.lock_inner().server.is_some() { + if !self.is_logged_in() { + return Err(CoreError::NotAuthenticated); + } + let api = self.api()?; + self.revoked(api.delete_account().await)?; + } + self.store.wipe()?; + let mut inner = self.lock_inner(); + let device = inner.device.take(); + *inner = Inner { device, ..Default::default() }; + Ok(()) + } } impl VaultCore { @@ -1000,6 +1021,21 @@ impl VaultCore { } /// Clones what an operation needs so no lock is held while it runs. + /// Re-derives the keys from `password` and the stored Secret Key; `BadCredentials` if they don't open the keyset. + fn verify_master_password(&self, password: &str) -> Result<()> { + let (acct, u) = self.unlocked_snapshot()?; + let sealed = acct.secret_key.as_ref().ok_or_else(|| CoreError::Invalid { + msg: "unlock with the master password and Secret Key once on this device first".into(), + })?; + let raw = u.keyset.enc.open(sealed, &secret_key_aad(acct.account_id))?; + let raw: [u8; 16] = raw.as_slice().try_into().map_err(|_| internal("stored Secret Key length"))?; + let derived = kdf::derive_keys(password, &SecretKey::from_bytes(raw), acct.account_id, &acct.kdf)?; + let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk); + AccountKeyset::unwrap(acct.account_id, &auk, &acct.wrapped_keyset) + .map(|_| ()) + .map_err(|_| CoreError::BadCredentials) + } + fn unlocked_snapshot(&self) -> Result<(AccountRecord, Unlocked)> { let inner = self.lock_inner(); let acct = inner.account.clone().ok_or(CoreError::NoAccount)?; diff --git a/crates/kv-ffi/src/tests.rs b/crates/kv-ffi/src/tests.rs index 1894ebc..9a083f4 100644 --- a/crates/kv-ffi/src/tests.rs +++ b/crates/kv-ffi/src/tests.rs @@ -639,3 +639,28 @@ async fn revoked_device_without_a_ticket_resets_and_asks_to_set_up_again() { // The old id can never come back. assert!(matches!(a.approve_device(old_id).await, Err(CoreError::NotFound))); } + +/// docs/03 "Deleting the account": the master password is checked again, and then nothing is left on the device. +#[tokio::test] +async fn delete_account_checks_the_password_and_wipes_the_device() { + 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(); + core.save_item(login_item("Mail", "pw")).unwrap(); + core.enable_local_unlock().unwrap(); + + assert!(matches!(core.delete_account("not my password".into()).await, Err(CoreError::BadCredentials))); + assert!(core.has_account()); + assert_eq!(core.list_items().unwrap().len(), 1); + + core.delete_account(PW.into()).await.unwrap(); + assert!(!core.has_account()); + assert!(!core.has_local_unlock()); + assert!(matches!(core.list_items(), Err(CoreError::Locked | CoreError::NoAccount))); + // Gone from disk too: a reopened vault has no account, and a new one can be created. + drop(core); + let reopened = VaultCore::open(path).unwrap(); + assert!(!reopened.has_account()); + reopened.create_account("a new master password".into()).unwrap(); +} diff --git a/crates/kv-ffi/tests/live_server.rs b/crates/kv-ffi/tests/live_server.rs index ac8c95c..b52e3a4 100644 --- a/crates/kv-ffi/tests/live_server.rs +++ b/crates/kv-ffi/tests/live_server.rs @@ -9,7 +9,8 @@ //! cargo test -p kv-ffi --test live_server -- --ignored --nocapture //! ``` //! The setup code works once, so restart the server with an empty database for every run (one test per run: -//! `two_devices_against_local_server` or `removed_device_reenrols_against_local_server`). They take a few minutes +//! `two_devices_against_local_server`, `removed_device_reenrols_against_local_server` or +//! `delete_account_against_local_server`). They take a few minutes //! because they wait out the server's rate limit on the login endpoints. use std::sync::Arc; @@ -193,3 +194,29 @@ async fn removed_device_reenrols_against_local_server() { assert!(matches!(web.approve_device(old_id).await, Err(CoreError::NotFound))); println!("removed device re-enrolled OK against {url}"); } + +#[tokio::test] +#[ignore = "needs a fresh local kestrove-server; see the module docs"] +async fn delete_account_against_local_server() { + let url = std::env::var("KESTROVE_SERVER_URL").unwrap_or_else(|_| "http://127.0.0.1:8080".into()); + let token = + std::env::var("KESTROVE_SETUP_TOKEN").expect("set KESTROVE_SETUP_TOKEN to the token the server printed"); + let pw = "a long master password for the live test"; + + let app = VaultCore::new_in_memory(); + app.create_account(pw.into()).unwrap(); + connect(&app, &url, "phone"); + app.register_account(token.clone()).await.unwrap(); + app.save_item(item("Bank", "pw-a")).unwrap(); + app.sync_now().await.unwrap(); + + assert!(matches!(app.delete_account("wrong".into()).await, Err(CoreError::BadCredentials))); + app.delete_account(pw.into()).await.unwrap(); + assert!(!app.has_account()); + // The server forgot the account: setup is open again and accepts its configured code. + let fresh = VaultCore::new_in_memory(); + fresh.create_account(pw.into()).unwrap(); + connect(&fresh, &url, "new phone"); + fresh.register_account(token).await.unwrap(); + println!("account deleted and server reusable OK against {url}"); +} diff --git a/crates/kv-sync/src/client.rs b/crates/kv-sync/src/client.rs index 3cc4974..9089029 100644 --- a/crates/kv-sync/src/client.rs +++ b/crates/kv-sync/src/client.rs @@ -290,6 +290,15 @@ impl ApiClient { r } + /// Deletes the account and everything stored for it on the server (docs/03 "Deleting the account"). + pub async fn delete_account(&self) -> Result<(), SyncError> { + let r = self.no_content(Method::DELETE, PATH_ACCOUNT, None::<&()>, true).await; + if r.is_ok() { + self.set_session(None); + } + r + } + pub async fn account(&self) -> Result { self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await }