sign_out_device: remove the vault from this device, keep the account on the server
ci / test (push) Canceled after 0s
ci / wasm (push) Canceled after 0s
ci / deny (push) Canceled after 0s
ci / fuzz (push) Canceled after 0s

Syncs first (fails if that sync fails, unless discard_unsynced), ends the session best effort and
wipes the local store, keeping the device identity. Refused for a vault with no server, which would
be lost. Test: unsynced item survives sign-out and returns after signing in again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-23 23:08:10 +07:00
co-authored by Claude Opus 5.5
parent a564633ffb
commit 2e16892287
2 changed files with 59 additions and 5 deletions
+32 -5
View File
@@ -991,11 +991,28 @@ impl VaultCore {
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(())
self.forget_local()
}
/// Signs this device out of Kestrove: the vault is removed from this device, while the account and its data
/// stay on the server, so signing in again (setup QR, or password + Secret Key) brings everything back.
/// Unless `discard_unsynced`, it syncs first and fails if that sync fails, so local changes aren't lost; the
/// app then asks before calling again with `discard_unsynced = true`. A vault with no server can't be signed
/// out of (it would be lost): `Invalid`; Delete Account is the way to remove it.
pub async fn sign_out_device(&self, discard_unsynced: bool) -> Result<()> {
if self.lock_inner().server.is_none() {
return Err(CoreError::Invalid {
msg: "this vault is only on this device; signing out would lose it (use Delete Account instead)".into(),
});
}
if !discard_unsynced && self.is_logged_in() && !self.is_locked() {
self.sync_now_inner().await?;
}
if self.is_logged_in() {
// Best effort: the session expires on the server anyway.
let _ = self.logout().await;
}
self.forget_local()
}
}
@@ -1021,6 +1038,16 @@ impl VaultCore {
}
/// Clones what an operation needs so no lock is held while it runs.
/// Wipes the local store and forgets the account and server; only the device identity is kept, so the next
/// setup enrols it again (or, after a sign-out, signs it back in as the same device).
fn forget_local(&self) -> Result<()> {
self.store.wipe()?;
let mut inner = self.lock_inner();
let device = inner.device.take();
*inner = Inner { device, ..Default::default() };
Ok(())
}
/// 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()?;
+27
View File
@@ -664,3 +664,30 @@ async fn delete_account_checks_the_password_and_wipes_the_device() {
assert!(!reopened.has_account());
reopened.create_account("a new master password".into()).unwrap();
}
/// Sign out of this device: the vault leaves the device, the account stays on the server, signing in brings it back.
#[tokio::test]
async fn sign_out_device_keeps_the_account_on_the_server() {
let server = TestServer::start().await;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("a.sqlite").to_string_lossy().into_owned();
let local = VaultCore::open(dir.path().join("local.sqlite").to_string_lossy().into_owned()).unwrap();
local.create_account(PW.into()).unwrap();
assert!(matches!(local.sign_out_device(false).await, Err(CoreError::Invalid { .. })));
assert!(local.has_account(), "a vault with no server is never signed out of");
let a = VaultCore::open(path.clone()).unwrap();
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", "pw-a")).unwrap(); // not synced yet: sign-out syncs it first
a.sign_out_device(false).await.unwrap();
assert!(!a.has_account() && !a.is_logged_in());
// Same device, signed in again: the item came back from the server.
connect(&a, &server);
assert_eq!(a.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
a.sync_now().await.unwrap();
assert_eq!(a.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
}