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();
}