From 2e168922879f1b65d4d74725c457996d356fd82e Mon Sep 17 00:00:00 2001 From: samialabri Date: Fri, 23 Oct 2026 23:08:10 +0700 Subject: [PATCH] sign_out_device: remove the vault from this device, keep the account on the server 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 --- crates/kv-ffi/src/lib.rs | 37 ++++++++++++++++++++++++++++++++----- crates/kv-ffi/src/tests.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index a4e7063..bee23d4 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -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()?; diff --git a/crates/kv-ffi/src/tests.rs b/crates/kv-ffi/src/tests.rs index 9a083f4..e43b055 100644 --- a/crates/kv-ffi/src/tests.rs +++ b/crates/kv-ffi/src/tests.rs @@ -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")); +}