diff --git a/crates/kv-ffi/src/import.rs b/crates/kv-ffi/src/import.rs index 8fed583..80dbe86 100644 --- a/crates/kv-ffi/src/import.rs +++ b/crates/kv-ffi/src/import.rs @@ -221,6 +221,9 @@ mod tests { fn sign(&self, message: Vec) -> Result> { kv_sync::DeviceSigner::sign(&self.0, &message).map_err(internal) } + fn reset(&self) -> Result<()> { + Err(internal("not supported in this test")) + } } fn connect(core: &VaultCore, server: &TestServer) { diff --git a/crates/kv-ffi/src/lib.rs b/crates/kv-ffi/src/lib.rs index 93366f5..52613a4 100644 --- a/crates/kv-ffi/src/lib.rs +++ b/crates/kv-ffi/src/lib.rs @@ -65,6 +65,12 @@ pub enum CoreError { /// The device is waiting for approval from an enrolled device. #[error("device approval pending")] PendingApproval, + /// This device was removed from the account (server 403 `device_revoked`, docs/03 "Removed devices"). + /// The core has already reset the device identity (`DeviceSigner::reset`) and kept the local vault data. + /// Show "This device was removed from your account. Set it up again." and the normal sign-in, which enrols + /// the new identity as a new device. + #[error("this device was removed from your account")] + DeviceRevoked, #[error("network error: {msg}")] Network { msg: String }, /// The server's TLS key matches none of the pins: possible interception. Never retry silently. @@ -110,6 +116,7 @@ impl From for CoreError { } SyncError::NotAuthenticated => CoreError::NotAuthenticated, SyncError::BadCredentials => CoreError::BadCredentials, + SyncError::DeviceRevoked => CoreError::DeviceRevoked, SyncError::NotFound => CoreError::NotFound, other => CoreError::Internal { msg: other.to_string() }, } @@ -243,6 +250,10 @@ pub trait DeviceSigner: Send + Sync { fn public_key(&self) -> Vec; /// ECDSA P-256 / SHA-256 over `message`, as raw `r || s` (64 bytes, not DER). fn sign(&self, message: Vec) -> std::result::Result, CoreError>; + /// The server removed this device (docs/03 "Removed devices"): delete the key and the stored device id, then + /// create a new key and a new random UUID. Afterwards `device_id` and `public_key` return the new identity. + /// Called by the core (never loops: at most once per failed call), or through `reset_device_identity`. + fn reset(&self) -> std::result::Result<(), CoreError>; } struct SignerAdapter { @@ -809,6 +820,8 @@ impl VaultCore { { return Ok(LoginResult::PendingApproval); } + // A removed device logged in under a new identity, with a new client. + let api = self.api()?; let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk); let acct = match local { @@ -867,9 +880,9 @@ impl VaultCore { { return Err(CoreError::PendingApproval); } - run().await? + self.revoked(run().await)? } - other => other?, + other => self.revoked(other)?, }; self.after_sync()?; Ok(SyncReport { @@ -887,7 +900,7 @@ impl VaultCore { impl VaultCore { /// Devices enrolled on the account, including ones waiting for approval. pub async fn list_devices(&self) -> Result> { - let devices = self.api()?.devices().await?; + let devices = self.revoked(self.api()?.devices().await)?; Ok(devices .into_iter() .map(|d| DeviceDto { @@ -905,12 +918,38 @@ impl VaultCore { /// Approves a pending device (after the user compared `key_fingerprint` on both screens). pub async fn approve_device(&self, device_id: String) -> Result<()> { - Ok(self.api()?.approve_device(parse_id(&device_id)?).await?) + self.revoked(self.api()?.approve_device(parse_id(&device_id)?).await) } - /// Revokes a device permanently and ends its sessions. + /// Revokes a device permanently and ends its sessions. The id can never be used again: tell the user "To add + /// it back later, set it up again as a new device." pub async fn revoke_device(&self, device_id: String) -> Result<()> { - Ok(self.api()?.revoke_device(parse_id(&device_id)?).await?) + self.revoked(self.api()?.revoke_device(parse_id(&device_id)?).await) + } + + /// Replaces this device's identity: `DeviceSigner::reset` (new key, new id), then forgets the session. The + /// core does this by itself on `DeviceRevoked`; local vault data is kept. The next login enrols a new device. + pub fn reset_device_identity(&self) -> Result<()> { + let (signer, old, name, platform) = { + let inner = self.lock_inner(); + let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?; + (d.signer.inner.clone(), d.signer.id, d.name.clone(), d.platform) + }; + signer.reset()?; + let id = Uuid::parse_str(&signer.device_id()) + .map_err(|_| CoreError::Invalid { msg: "device id must be a UUID".into() })?; + if id == old { + return Err(internal("the device id did not change on reset")); + } + self.store.delete_meta(META_SESSION)?; + let mut inner = self.lock_inner(); + inner.device = Some(Device { signer: Arc::new(SignerAdapter { id, inner: signer }), name, platform }); + inner.api = None; + // A removed device signs in again explicitly: the kept OPAQUE input must not silently enrol it. + if let Some(u) = inner.unlocked.as_mut() { + u.auth_input = None; + } + Ok(()) } /// This device's key fingerprint (same format as `DeviceDto.key_fingerprint`), or `NotConfigured`. @@ -1033,7 +1072,20 @@ impl VaultCore { ticket: Option, ) -> Result { let device = self.enroll()?; - match api.login_with_ticket(account_id, auth_input, device, totp, ticket).await? { + let outcome = match api.login_with_ticket(account_id, auth_input, device, totp.clone(), ticket.clone()).await { + // docs/03 "Removed devices": start over as a new device. With a ticket in hand (Add a device, scan-to-join) + // retry once at once, as that new device; the refused login did not use the ticket up. + Err(SyncError::DeviceRevoked) => { + self.reset_device_identity()?; + if ticket.is_none() { + return Err(CoreError::DeviceRevoked); + } + let api = self.api()?; + api.login_with_ticket(account_id, auth_input, self.enroll()?, totp, ticket).await? + } + other => other?, + }; + match outcome { LoginOutcome::Ok { session } => { self.store.put_meta(META_SESSION, session.as_bytes())?; Ok(LoginResult::Ok) @@ -1042,6 +1094,14 @@ impl VaultCore { } } + /// Passes `r` through; on `DeviceRevoked` from a signed call, resets the device identity first. + fn revoked(&self, r: std::result::Result) -> Result { + if let Err(SyncError::DeviceRevoked) = r { + self.reset_device_identity()?; + } + Ok(r?) + } + /// Persists a renewed session and picks the primary vault on a newly enrolled device. fn after_sync(&self) -> Result<()> { let api_session = self.lock_inner().api.as_ref().and_then(|a| a.session()); diff --git a/crates/kv-ffi/src/onboarding.rs b/crates/kv-ffi/src/onboarding.rs index ae23fdd..1d017ef 100644 --- a/crates/kv-ffi/src/onboarding.rs +++ b/crates/kv-ffi/src/onboarding.rs @@ -136,13 +136,13 @@ impl VaultCore { /// The "Add a device" QR: issues a 10-minute, single-use, unbound enrollment ticket and returns the /// payload. Show it as a secret view; on Cancel call `revoke_enrollment_ticket`. pub async fn add_device_payload(&self) -> Result { - let created = self.api()?.create_ticket(None).await?; + let created = self.revoked(self.api()?.create_ticket(None).await)?; let payload = self.setup_payload(Some(created.ticket))?.encode()?.to_string(); Ok(AddDevicePayload { payload, ticket_id: created.ticket_id.to_string(), expires_at: created.expires_at }) } pub async fn revoke_enrollment_ticket(&self, ticket_id: String) -> Result<()> { - Ok(self.api()?.revoke_ticket(parse_id(&ticket_id)?).await?) + self.revoked(self.api()?.revoke_ticket(parse_id(&ticket_id)?).await) } /// New device: configures the server and pins from `setup`, then logs in with the master password and the @@ -243,7 +243,7 @@ impl VaultCore { } self.unlocked_snapshot()?; let keypair = PairingKeypair::generate(); - let hello = api.join_hello(&qr.rendezvous_id, keypair.public_bytes().to_vec()).await?; + let hello = self.revoked(api.join_hello(&qr.rendezvous_id, keypair.public_bytes().to_vec()).await)?; let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?; let confirm = JoinConfirm { code: pairing::sas(&rid_bytes, &qr.public_key, &keypair.public_bytes()), @@ -262,11 +262,11 @@ impl VaultCore { return Err(invalid("no join to approve")); }; let api = self.api()?; - let ticket = api.create_ticket(Some(Sha256::digest(&device.p256_pk).to_vec())).await?; + let ticket = self.revoked(api.create_ticket(Some(Sha256::digest(&device.p256_pk).to_vec())).await)?; let payload = self.setup_payload(Some(ticket.ticket))?.encode()?; let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?; let bundle = pairing::seal(&keypair, &qr.public_key, qr.qr_secret.as_ref(), &rid_bytes, payload.as_bytes())?; - Ok(api.join_send(&qr.rendezvous_id, bundle).await?) + self.revoked(api.join_send(&qr.rendezvous_id, bundle).await) } } diff --git a/crates/kv-ffi/src/passkeys/tests.rs b/crates/kv-ffi/src/passkeys/tests.rs index 7cb4c44..189fe17 100644 --- a/crates/kv-ffi/src/passkeys/tests.rs +++ b/crates/kv-ffi/src/passkeys/tests.rs @@ -173,6 +173,9 @@ impl DeviceSigner for TestSigner { fn sign(&self, message: Vec) -> crate::Result> { kv_sync::DeviceSigner::sign(&self.0, &message).map_err(crate::internal) } + fn reset(&self) -> crate::Result<()> { + Err(crate::internal("not supported in this test")) + } } fn connect(core: &VaultCore, server: &TestServer) { diff --git a/crates/kv-ffi/src/tests.rs b/crates/kv-ffi/src/tests.rs index c30f044..1894ebc 100644 --- a/crates/kv-ffi/src/tests.rs +++ b/crates/kv-ffi/src/tests.rs @@ -23,28 +23,42 @@ fn login_item(title: &str, password: &str) -> ItemDto { } } -struct TestSigner(SoftwareSigner); +/// A platform signer stand-in whose identity can be reset, like the apps' Keychain / Keystore ones. +struct TestSigner { + key: Mutex, + resets: std::sync::atomic::AtomicUsize, +} + +impl TestSigner { + fn new() -> Self { + Self { key: Mutex::new(SoftwareSigner::generate(Uuid::new_v4())), resets: Default::default() } + } + + fn key(&self) -> std::sync::MutexGuard<'_, SoftwareSigner> { + self.key.lock().unwrap() + } +} impl DeviceSigner for TestSigner { fn device_id(&self) -> String { - kv_sync::DeviceSigner::device_id(&self.0).to_string() + kv_sync::DeviceSigner::device_id(&*self.key()).to_string() } fn public_key(&self) -> Vec { - kv_sync::DeviceSigner::public_key(&self.0) + kv_sync::DeviceSigner::public_key(&*self.key()) } fn sign(&self, message: Vec) -> Result> { - kv_sync::DeviceSigner::sign(&self.0, &message).map_err(internal) + kv_sync::DeviceSigner::sign(&*self.key(), &message).map_err(internal) + } + fn reset(&self) -> Result<()> { + *self.key() = SoftwareSigner::generate(Uuid::new_v4()); + self.resets.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) } } fn connect(core: &VaultCore, server: &TestServer) { core.configure_server(server.url(), vec![]).unwrap(); - core.set_device_signer( - Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))), - "test".into(), - PlatformDto::Macos, - ) - .unwrap(); + core.set_device_signer(Arc::new(TestSigner::new()), "test".into(), PlatformDto::Macos).unwrap(); } #[test] @@ -260,9 +274,10 @@ async fn pending_approval_and_biometric_session_expiry() { a.approve_device(pending.device_id.clone()).await.unwrap(); assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok); b.sync_now().await.unwrap(); - // Revoking B kills its session. + // Revoking B kills its session; B learns it was removed and resets its identity. a.revoke_device(pending.device_id.clone()).await.unwrap(); - assert!(matches!(b.sync_now().await, Err(CoreError::NotAuthenticated | CoreError::PendingApproval))); + assert!(matches!(b.sync_now().await, Err(CoreError::DeviceRevoked))); + assert_ne!(b.device_key_fingerprint().unwrap(), pending.key_fingerprint); // A biometric unlock without a stored OPAQUE input cannot renew the session: NotAuthenticated. let c = VaultCore::new_in_memory(); @@ -407,12 +422,7 @@ async fn add_device_qr_needs_only_the_password_and_no_approval() { // The new device has nothing configured but its key; the QR and the password are enough. let b = VaultCore::new_in_memory(); - b.set_device_signer( - Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))), - "tablet".into(), - PlatformDto::Android, - ) - .unwrap(); + b.set_device_signer(Arc::new(TestSigner::new()), "tablet".into(), PlatformDto::Android).unwrap(); assert!(matches!( b.login_with_setup(setup.clone(), "wrong password!!".into()).await, Err(CoreError::BadCredentials) @@ -444,13 +454,7 @@ async fn scan_to_join_between_two_clients() { // Desktop: only knows the server URL. let desktop = VaultCore::new_in_memory(); - desktop - .set_device_signer( - Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))), - "Sami's MacBook".into(), - PlatformDto::Macos, - ) - .unwrap(); + desktop.set_device_signer(Arc::new(TestSigner::new()), "Sami's MacBook".into(), PlatformDto::Macos).unwrap(); assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. }))); let session = desktop.join_start(format!("{}/", server.url())).await.unwrap(); assert!(session.qr.starts_with("kestrove://join/v1?") && session.qr.len() < 200); @@ -555,3 +559,83 @@ async fn scan_to_join_rejects_a_forged_bundle() { assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. }))); assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })), "state cleared"); } + +/// docs/03 "Removed devices", end to end: a phone waiting for approval is denied (revoked), keeps its identity +/// across a "reinstall", scans a fresh "Add a device" QR, and gets straight in as a new device with that same +/// ticket. Its old id stays revoked. +#[tokio::test] +async fn revoked_device_resets_and_reenrols_with_the_same_ticket() { + let server = TestServer::start().await; + let (a, sk, id) = phone_with_item(&server).await; + + // The phone asks to join and is denied. + let signer = Arc::new(TestSigner::new()); + let phone = VaultCore::new_in_memory(); + phone.configure_server(server.url(), vec![]).unwrap(); + phone.set_device_signer(signer.clone(), "iPhone".into(), PlatformDto::Ios).unwrap(); + let old_id = signer.device_id(); + assert_eq!(phone.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval); + a.revoke_device(old_id.clone()).await.unwrap(); + + // Reinstall: a new core, the same platform identity. A wrong password reveals nothing and resets nothing. + let phone = VaultCore::new_in_memory(); + phone.set_device_signer(signer.clone(), "iPhone".into(), PlatformDto::Ios).unwrap(); + let add = a.add_device_payload().await.unwrap(); + let setup = parse_setup_payload(add.payload).unwrap(); + assert!(matches!( + phone.login_with_setup(setup.clone(), "wrong password!!".into()).await, + Err(CoreError::BadCredentials) + )); + assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 0); + + // Right password: device_revoked → reset → one retry with the same ticket → in, no approval needed. + assert_eq!(phone.login_with_setup(setup, PW.into()).await.unwrap(), LoginResult::Ok); + assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 1); + let new_id = signer.device_id(); + assert_ne!(new_id, old_id); + phone.sync_now().await.unwrap(); + assert_eq!(phone.get_item(id).unwrap().password.as_deref(), Some("pw-a")); + + let devices = a.list_devices().await.unwrap(); + let old = devices.iter().find(|d| d.device_id == old_id).unwrap(); + let new = devices.iter().find(|d| d.device_id == new_id).unwrap(); + assert!(old.revoked && !old.approved); + assert!(new.approved && !new.revoked); + assert!(server.state().tickets.values().all(|t| t.used_by.is_some())); +} + +/// Without a ticket the core resets and tells the app; the next sign-in waits for approval as a new device, +/// and the local vault data stays. +#[tokio::test] +async fn revoked_device_without_a_ticket_resets_and_asks_to_set_up_again() { + let server = TestServer::start().await; + let (a, sk, id) = phone_with_item(&server).await; + server.state().require_approval = false; + let signer = Arc::new(TestSigner::new()); + let b = VaultCore::new_in_memory(); + b.configure_server(server.url(), vec![]).unwrap(); + b.set_device_signer(signer.clone(), "laptop".into(), PlatformDto::Linux).unwrap(); + assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok); + b.sync_now().await.unwrap(); + let old_id = signer.device_id(); + + a.revoke_device(old_id.clone()).await.unwrap(); + assert!(matches!(b.sync_now().await, Err(CoreError::DeviceRevoked))); + assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!(!b.is_logged_in()); + // Still unlocked with its data, but it will not sign in again on its own. + assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a")); + assert!(matches!(b.sync_now().await, Err(CoreError::NotAuthenticated))); + assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 1); + + server.state().require_approval = true; + assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval); + let new_id = signer.device_id(); + assert_ne!(new_id, old_id); + a.approve_device(new_id).await.unwrap(); + assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok); + b.sync_now().await.unwrap(); + assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw-a")); + // The old id can never come back. + assert!(matches!(a.approve_device(old_id).await, Err(CoreError::NotFound))); +} diff --git a/crates/kv-ffi/tests/live_server.rs b/crates/kv-ffi/tests/live_server.rs index cc6d8b2..0665672 100644 --- a/crates/kv-ffi/tests/live_server.rs +++ b/crates/kv-ffi/tests/live_server.rs @@ -29,6 +29,9 @@ impl DeviceSigner for Signer { fn sign(&self, message: Vec) -> Result, CoreError> { kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| CoreError::Internal { msg: e.to_string() }) } + fn reset(&self) -> Result<(), CoreError> { + Err(CoreError::Internal { msg: "not supported in this test".into() }) + } } fn connect(core: &VaultCore, url: &str, name: &str) { diff --git a/crates/kv-sync/src/client.rs b/crates/kv-sync/src/client.rs index 97ff915..3cc4974 100644 --- a/crates/kv-sync/src/client.rs +++ b/crates/kv-sync/src/client.rs @@ -20,6 +20,8 @@ use crate::{ }; pub const PAGE_LIMIT: u32 = 500; +/// Error code of the 403 for a removed device (docs/03 "Removed devices"). +pub const DEVICE_REVOKED: &str = "device_revoked"; /// Result of an OPAQUE login. #[derive(Debug, Clone, PartialEq, Eq)] @@ -122,6 +124,10 @@ impl ApiClient { StatusCode::PRECONDITION_FAILED => SyncError::RevisionConflict, StatusCode::CONFLICT => SyncError::VersionConflict, StatusCode::NOT_FOUND => SyncError::NotFound, + StatusCode::FORBIDDEN if code == DEVICE_REVOKED => { + self.set_session(None); + SyncError::DeviceRevoked + } StatusCode::UNAUTHORIZED if signed => { self.set_session(None); SyncError::NotAuthenticated diff --git a/crates/kv-sync/src/lib.rs b/crates/kv-sync/src/lib.rs index 37d9ec0..eb999ca 100644 --- a/crates/kv-sync/src/lib.rs +++ b/crates/kv-sync/src/lib.rs @@ -49,6 +49,10 @@ pub enum SyncError { NotAuthenticated, #[error("wrong master password or Secret Key")] BadCredentials, + /// HTTP 403 `device_revoked` (docs/03 "Removed devices"): this device id was removed from the account and + /// can never be used again. The client must reset its device identity and enrol as a new device. + #[error("this device was removed from the account")] + DeviceRevoked, #[error("authentication failed: {0}")] Auth(String), #[error("device signer: {0}")] diff --git a/crates/kv-sync/src/test_server.rs b/crates/kv-sync/src/test_server.rs index 2063379..e3aa889 100644 --- a/crates/kv-sync/src/test_server.rs +++ b/crates/kv-sync/src/test_server.rs @@ -195,13 +195,19 @@ impl Respond for Handler { fn authenticate(st: &State, req: &Request) -> Result { let header = |name: &str| req.headers.get(name).and_then(|v| v.to_str().ok()).map(str::to_string); let session = header(signing::HDR_SESSION).ok_or_else(|| err(401, "unauthorized"))?; - let device = *st.sessions.get(&session).ok_or_else(|| err(401, "unauthorized"))?; - let dev = st.devices.get(&device).ok_or_else(|| err(401, "unauthorized"))?; // wiremock rebuilds `req.url` without the port; the signed @target-uri is the URL the client used. let mut target = format!("{}{}", st.base_url, req.url.path()); if let Some(q) = req.url.query() { target = format!("{target}?{q}"); } + let Some(&device) = st.sessions.get(&session) else { + // docs/03 "Removed devices": a valid signature by a revoked device's key gets 403 device_revoked. + let revoked_signer = st.devices.iter().filter(|(_, d)| d.revoked).any(|(id, d)| { + signing::verify_request(&d.p256_pk, req.method.as_str(), &target, &req.body, &header).ok() == Some(*id) + }); + return Err(if revoked_signer { err(403, "device_revoked") } else { err(401, "unauthorized") }); + }; + let dev = st.devices.get(&device).ok_or_else(|| err(401, "unauthorized"))?; let key_id = signing::verify_request(&dev.p256_pk, req.method.as_str(), &target, &req.body, &header) .map_err(|_| err(401, "bad_signature"))?; if key_id != device || !dev.approved { @@ -293,6 +299,10 @@ fn handle(st: &mut State, req: &Request) -> Result::deserialize(&state).map_err(|_| err(401, "unauthorized"))?; let ke3 = CredentialFinalization::deserialize(&r.ke3).map_err(|_| err(401, "unauthorized"))?; login.finish(ke3).map_err(|_| err(401, "unauthorized"))?; + // Checked after OPAQUE and before the ticket, which therefore stays unused (docs/03 "Removed devices"). + if st.devices.get(&device).is_some_and(|d| d.revoked) { + return Err(err(403, "device_revoked")); + } if let Some(secret) = &r.ticket { redeem_ticket(st, secret, device); } diff --git a/crates/kv-sync/tests/sync.rs b/crates/kv-sync/tests/sync.rs index 9568828..e2b14e0 100644 --- a/crates/kv-sync/tests/sync.rs +++ b/crates/kv-sync/tests/sync.rs @@ -439,6 +439,23 @@ async fn auth_errors() { // The account endpoint gives a new device what it needs to unlock. let info = c.api.account().await.unwrap(); assert_eq!(info.sig_pk, a.keyset.sig_public().to_vec()); + + // Removed device (docs/03 "Removed devices"): its signed calls and its logins say DeviceRevoked, but only with + // the right password, and the refused login leaves the ticket unused. + assert!(a.login().await != LoginOutcome::PendingApproval); + a.api.revoke_device(c.signer.device_id()).await.unwrap(); + assert!(matches!(c.sync().await, Err(SyncError::DeviceRevoked))); + assert!(c.api.session().is_none()); + let err = c.api.login(c.keyset.account_id, &[0u8; 32], enroll(&c.signer), None).await.unwrap_err(); + assert!(matches!(err, SyncError::BadCredentials), "{err}"); + let ticket = a.api.create_ticket(None).await.unwrap(); + let err = c + .api + .login_with_ticket(c.keyset.account_id, &AUTH, enroll(&c.signer), None, Some(ticket.ticket.clone())) + .await + .unwrap_err(); + assert!(matches!(err, SyncError::DeviceRevoked), "{err}"); + assert_eq!(a.api.tickets().await.unwrap().len(), 1); } // ---------------------------------------------------------------- TLS pinning diff --git a/crates/kv-wasm/README.md b/crates/kv-wasm/README.md index 170652c..ae0ed05 100644 --- a/crates/kv-wasm/README.md +++ b/crates/kv-wasm/README.md @@ -80,7 +80,7 @@ parseSetupPayload(s): SetupInfo; // {serverUrl, s Errors reject with an `Error` whose `name` is `"CoreError"` and whose `code` is one of kv-ffi's `CoreError` cases (`Locked`, `NoAccount`, `BadCredentials`, `Invalid`, `NotFound`, `Internal`, -`NotConfigured`, `NotAuthenticated`, `PendingApproval`, `Network`, `Server` + `status`, +`NotConfigured`, `NotAuthenticated`, `PendingApproval`, `DeviceRevoked`, `Network`, `Server` + `status`, `IntegrityAlarm` + `vaultId`/`reason`), plus `Busy` (a sync is already running) and, from the worker client, `InUse` (another tab has the vault open). @@ -125,14 +125,22 @@ two replicas overwriting each other. `NotAuthenticated` (ask for the password and `login` again), `PendingApproval`, `IntegrityAlarm` (stop and alert: the server rolled back or tampered with a vault) and `restoredItemIds` (tell the user an edit brought a deleted item back). -7. **Storage.** Call `navigator.storage.persist()`. If the browser evicts IndexedDB, unsynced edits +7. **Removed browser** (kestrove-spec docs/03 "Removed devices"). `DeviceRevoked` from any call means + the server removed this browser's device id for good. The vault has already replaced the WebCrypto + key and device id in IndexedDB (`deviceId()` changes), dropped the session and kept the local data. + `loginWithSetup` with a ticket retries once by itself as the new device and normally resolves `"ok"`. + Otherwise show "This device was removed from your account. Set it up again." and the sign-in: the + next `login` enrols the new identity (`"pendingApproval"`). `resetDeviceIdentity()` does the same + reset on demand. When the user removes a device, warn "To add it back later, set it up again as a new + device." +8. **Storage.** Call `navigator.storage.persist()`. If the browser evicts IndexedDB, unsynced edits and the device key are lost and the browser must be approved again. -8. **Sign out of this browser:** `logout()` (ends the server session), then `wipe()` (removes the +9. **Sign out of this browser:** `logout()` (ends the server session), then `wipe()` (removes the replica and device key; the next login is a new device). -9. **Import/export:** pass `new Uint8Array(await file.arrayBuffer())`; `exportEncrypted` resolves with +10. **Import/export:** pass `new Uint8Array(await file.arrayBuffer())`; `exportEncrypted` resolves with the `.kvex` bytes for a `Blob` download. -10. `itemsForUrl` / `matchUrl` match the same host only (no Public Suffix List in this build). -11. **Onboarding** (docs/10). Same flows and names as kv-ffi; `expiresAt` is Unix **seconds** +11. `itemsForUrl` / `matchUrl` match the same host only (no Public Suffix List in this build). +12. **Onboarding** (docs/10). Same flows and names as kv-ffi; `expiresAt` is Unix **seconds** and codes look like `"123 456"`, as in kv-ffi. * *Sign in with a setup code*: `parseSetupPayload(text)` (scanned or pasted; it is a secret, so never put it in a URL or register `kestrove://` as a handler), then diff --git a/crates/kv-wasm/src/core.rs b/crates/kv-wasm/src/core.rs index 1d58644..504426e 100644 --- a/crates/kv-wasm/src/core.rs +++ b/crates/kv-wasm/src/core.rs @@ -463,6 +463,20 @@ impl WebCore { self.lock_inner().device.as_ref().map(|d| d.signer.device_id()) } + /// After `DeviceRevoked` (docs/03 "Removed devices"): forgets the session and the kept OPAQUE input, so that + /// the new identity the caller installs next (`set_device_signer`) is enrolled only by an explicit login. + /// Local vault data stays. The browser binding calls this and replaces the WebCrypto key; it then retries a + /// ticket login once. + pub fn forget_revoked_device(&self) -> Result<()> { + self.store.delete_meta(META_SESSION)?; + let mut inner = self.lock_inner(); + inner.api = None; + if let Some(u) = inner.unlocked.as_mut() { + u.auth_input = None; + } + Ok(()) + } + pub fn device_key_fingerprint(&self) -> Result { let inner = self.lock_inner(); let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?; diff --git a/crates/kv-wasm/src/error.rs b/crates/kv-wasm/src/error.rs index 30d76da..b6643e1 100644 --- a/crates/kv-wasm/src/error.rs +++ b/crates/kv-wasm/src/error.rs @@ -28,6 +28,11 @@ pub enum CoreError { /// This browser is waiting for approval from an enrolled device. #[error("device approval pending")] PendingApproval, + /// This browser was removed from the account (server 403 `device_revoked`, docs/03 "Removed devices"). The + /// vault has already replaced its device key and id and kept the local data. Show "This device was removed + /// from your account. Set it up again." and the normal sign-in, which enrols it as a new device. + #[error("this device was removed from your account")] + DeviceRevoked, #[error("network error: {msg}")] Network { msg: String }, /// Native only (TLS pinning); never raised in the browser. Kept so the code set matches kv-ffi. @@ -55,6 +60,7 @@ impl CoreError { CoreError::NotConfigured => "NotConfigured", CoreError::NotAuthenticated => "NotAuthenticated", CoreError::PendingApproval => "PendingApproval", + CoreError::DeviceRevoked => "DeviceRevoked", CoreError::Network { .. } => "Network", CoreError::PinMismatch => "PinMismatch", CoreError::Server { .. } => "Server", @@ -103,6 +109,7 @@ impl From for CoreError { } SyncError::NotAuthenticated => CoreError::NotAuthenticated, SyncError::BadCredentials => CoreError::BadCredentials, + SyncError::DeviceRevoked => CoreError::DeviceRevoked, SyncError::NotFound => CoreError::NotFound, other => CoreError::Internal { msg: other.to_string() }, } diff --git a/crates/kv-wasm/src/wasm/mod.rs b/crates/kv-wasm/src/wasm/mod.rs index c64debb..199c248 100644 --- a/crates/kv-wasm/src/wasm/mod.rs +++ b/crates/kv-wasm/src/wasm/mod.rs @@ -171,7 +171,8 @@ export interface JoinConfirm { export type CoreErrorCode = | "Locked" | "NoAccount" | "BadCredentials" | "Invalid" | "NotFound" | "Internal" | "NotConfigured" - | "NotAuthenticated" | "PendingApproval" | "Network" | "PinMismatch" | "Server" | "IntegrityAlarm" | "Busy"; + | "NotAuthenticated" | "PendingApproval" | "DeviceRevoked" | "Network" | "PinMismatch" | "Server" | "IntegrityAlarm" + | "Busy"; /** What every rejected promise carries. `status` is set for "Server"; `vaultId`/`reason` for "IntegrityAlarm". */ export interface CoreError extends Error { @@ -310,6 +311,23 @@ impl Shared { let d = self.device.borrow(); self.core.set_device_signer(d.signer.clone(), &d.name, d.platform); } + + /// docs/03 "Removed devices": the server removed this browser. Replace the WebCrypto key and the device id + /// (same name and platform) in IndexedDB, forget the session, keep the vault data. + async fn reset_device(&self) -> Result<()> { + self.core.forget_revoked_device()?; + let mut fresh = new_device().await.map_err(js_internal)?; + { + let old = self.device.borrow(); + old.signer.forget(); + fresh.name = old.name.clone(); + fresh.platform = old.platform; + } + *self.device.borrow_mut() = fresh; + self.install_device(); + self.save_device().await?; + self.persist().await + } } /// The vault in this browser. Create it with `VaultCore.open()` (IndexedDB) or @@ -376,7 +394,14 @@ impl VaultCore { Fut: Future> + 'static, { let s = self.shared.clone(); - future_to_promise(async move { op(s).await.map_err(|e| core_error(&e)).and_then(|v| to_js(&v)) }) + future_to_promise(async move { + let r = op(s.clone()).await; + // Any call the server refused as a removed device starts this browser over as a new one. + if let Err(CoreError::DeviceRevoked) = r { + s.reset_device().await.map_err(|e| core_error(&e))?; + } + r.map_err(|e| core_error(&e)).and_then(|v| to_js(&v)) + }) } /// Like `run`, then flushes changed entries to IndexedDB, even when `op` failed part-way @@ -599,6 +624,13 @@ impl VaultCore { }) } + /// Replaces this browser's device key and id (the vault does it by itself on `DeviceRevoked`). Local data + /// stays; the next login enrols a new device. + #[wasm_bindgen(js_name = resetDeviceIdentity, unchecked_return_type = "Promise")] + pub fn reset_device_identity(&self) -> Promise { + self.run(|s| async move { s.reset_device().await }) + } + #[wasm_bindgen(js_name = deviceId, unchecked_return_type = "Promise")] pub fn device_id(&self) -> Promise { self.run(|s| async move { s.core.device_id().map(|d| d.to_string()).ok_or(CoreError::NotConfigured) }) @@ -696,7 +728,15 @@ impl VaultCore { let (setup, pw) = (from_js::(setup), zeroize::Zeroizing::new(master_password)); self.write(move |s| async move { let setup = zeroize::Zeroizing::new(setup?); - s.core.login_with_setup(&setup, &pw).await + match s.core.login_with_setup(&setup, &pw).await { + // A removed browser holding a ticket: new key and id, then the same login once more, as that new + // device (the refused login did not use the ticket up). + Err(CoreError::DeviceRevoked) if setup.ticket.is_some() => { + s.reset_device().await?; + s.core.login_with_setup(&setup, &pw).await + } + other => other, + } }) } diff --git a/crates/kv-wasm/src/wasm/tests.rs b/crates/kv-wasm/src/wasm/tests.rs index a618be7..2b5a622 100644 --- a/crates/kv-wasm/src/wasm/tests.rs +++ b/crates/kv-wasm/src/wasm/tests.rs @@ -123,6 +123,34 @@ async fn account_lifecycle_persists_in_indexeddb() { db.close(); } +/// docs/03 "Removed devices": a reset replaces the WebCrypto key and device id in IndexedDB (same name and +/// platform), keeps the account and items, and survives a reload. +#[wasm_bindgen_test] +async fn reset_device_identity_replaces_the_stored_key_and_keeps_the_vault() { + let db_name = format!("kv-test-{}", Uuid::new_v4()); + let core = open_db(&db_name).await; + ok(core.set_device("Firefox on Mac".into(), Some("macos".into()))).await; + ok(core.create_account(PW.into())).await; + let id = ok(core.save_item(js(login("Mail", "kept-after-reset")))).await.as_string().unwrap(); + let (old_id, old_fp) = + (ok(core.device_id()).await.as_string().unwrap(), ok(core.device_key_fingerprint()).await.as_string().unwrap()); + + ok(core.reset_device_identity()).await; + let new_id = ok(core.device_id()).await.as_string().unwrap(); + assert_ne!(new_id, old_id); + assert_ne!(ok(core.device_key_fingerprint()).await.as_string().unwrap(), old_fp); + let item: ItemDto = de(ok(core.get_item(id.clone())).await); + assert_eq!(item.password.as_deref(), Some("kept-after-reset")); + let db = core.shared.db.clone().unwrap(); + db.close(); + drop(core); + + let core = open_db(&db_name).await; + assert_eq!(ok(core.device_id()).await.as_string().unwrap(), new_id); + let d = core.shared.device.borrow(); + assert_eq!((d.name.as_str(), d.platform), ("Firefox on Mac", Platform::Macos)); +} + #[wasm_bindgen_test] async fn webcrypto_device_key_signs_rfc9421_requests() { let (signer, _private) = WebCryptoSigner::generate().await.unwrap(); diff --git a/crates/kv-wasm/tests/sync.rs b/crates/kv-wasm/tests/sync.rs index 4275858..72f2406 100644 --- a/crates/kv-wasm/tests/sync.rs +++ b/crates/kv-wasm/tests/sync.rs @@ -32,6 +32,9 @@ impl DeviceSigner for Signer { fn sign(&self, message: Vec) -> Result, kv_ffi::CoreError> { kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| kv_ffi::CoreError::Internal { msg: e.to_string() }) } + fn reset(&self) -> Result<(), kv_ffi::CoreError> { + Err(kv_ffi::CoreError::Internal { msg: "not supported in this test".into() }) + } } fn web_item(title: &str, password: &str) -> ItemDto { @@ -371,3 +374,46 @@ async fn web_vault_join_refuses_a_swapped_approver() { web.join_cancel(); assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. }))); } + +/// docs/03 "Removed devices" for the web core: a removed browser gets `DeviceRevoked` (only with the right +/// password), forgets its session, and the same "Add a device" ticket gets it back in under a new key and id +/// (what the browser binding does: `forget_revoked_device`, new WebCrypto key, one retry). +#[tokio::test] +async fn removed_web_vault_reenrols_with_the_same_ticket() { + let server = TestServer::start().await; + let (app, _sk, id) = app_with_item(&server).await; + let add = app.add_device_payload().await.unwrap(); + let setup: kv_wasm::dto::SetupInfo = { + let i = kv_ffi::parse_setup_payload(add.payload.clone()).unwrap(); + kv_wasm::dto::SetupInfo { + server_url: i.server_url, + spki_pins: i.spki_pins, + account_id: i.account_id, + secret_key: i.secret_key, + ticket: i.ticket, + } + }; + + // The browser enrolled once (pending), then was denied. + let (store, web) = new_web(&server); + let old = web.device_id().unwrap(); + let no_ticket = kv_wasm::dto::SetupInfo { ticket: None, ..setup.clone() }; + assert_eq!(web.login_with_setup(&no_ticket, PW).await.unwrap(), LoginResult::PendingApproval); + app.revoke_device(old.to_string()).await.unwrap(); + + assert!(matches!(web.login_with_setup(&setup, "wrong password!!").await, Err(CoreError::BadCredentials))); + assert!(matches!(web.login_with_setup(&setup, PW).await, Err(CoreError::DeviceRevoked))); + web.forget_revoked_device().unwrap(); + web.set_device_signer(Arc::new(SoftwareSigner::generate(Uuid::new_v4())), "Web vault", Platform::Macos); + assert_ne!(web.device_id().unwrap(), old); + assert_eq!(web.login_with_setup(&setup, PW).await.unwrap(), LoginResult::Ok); + web.sync_now().await.unwrap(); + assert_eq!(web.get_item(&id).unwrap().password.as_deref(), Some("from the app")); + drop(store); + + // Signed in, then removed: sync says so; the old id never comes back. + app.revoke_device(web.device_id().unwrap().to_string()).await.unwrap(); + assert!(matches!(web.sync_now().await, Err(CoreError::DeviceRevoked))); + web.forget_revoked_device().unwrap(); + assert!(matches!(app.approve_device(old.to_string()).await, Err(kv_ffi::CoreError::NotFound))); +}