delete_account: re-check the master password, delete on the server, wipe the device
ci / test (push) Canceled after 0s
ci / wasm (push) Canceled after 0s
ci / deny (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s

VaultCore::delete_account verifies the password against the keyset (Secret Key from its sealed copy),
calls DELETE /v1/account when a server is configured (needs a session; a failure deletes nothing),
then wipes the local store and keeps only the device identity. Unit test plus a live test against a
real server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-22 16:12:46 +09:00
co-authored by Claude Opus 5.5
parent 86e90569f2
commit a564633ffb
4 changed files with 98 additions and 1 deletions
+36
View File
@@ -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)?;
+25
View File
@@ -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();
}
+28 -1
View File
@@ -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}");
}
+9
View File
@@ -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<AccountInfo, SyncError> {
self.json(Method::GET, PATH_ACCOUNT, None::<&()>, None, true).await
}