delete_account: re-check the master password, delete on the server, wipe the device
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:
@@ -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)?;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user