Removed devices: DeviceRevoked, identity reset and one retry with the ticket

kv-sync: SyncError::DeviceRevoked for 403 device_revoked (drops the session);
the test server answers it after OPAQUE (before the ticket) and for valid
signatures by a revoked device's key.

kv-ffi: CoreError::DeviceRevoked; the foreign DeviceSigner gains reset() (new key
and id). On device_revoked the core resets the identity, forgets the session
and the kept OPAQUE input (no silent re-enrolment) and keeps the vault data; a
login carrying a ticket retries once as the new device. reset_device_identity()
runs the reset on demand. Signed calls (sync, devices, tickets, join) reset too.

kv-wasm: DeviceRevoked; the browser binding replaces the WebCrypto key and id in
IndexedDB (same name and platform), loginWithSetup retries once with a ticket;
resetDeviceIdentity().

Tests: end to end revoked -> reset -> re-enrol with the same ticket (kv-ffi and
web core), no-ticket path, in-browser IndexedDB reset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-20 22:25:03 +09:00
co-authored by Claude Opus 5.5
parent 460580bcc8
commit 83d56d43e2
16 changed files with 381 additions and 48 deletions
+14 -6
View File
@@ -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
+14
View File
@@ -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<String> {
let inner = self.lock_inner();
let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?;
+7
View File
@@ -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<SyncError> 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() },
}
+43 -3
View File
@@ -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<Output = Result<T>> + '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<void>")]
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<string>")]
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::<SetupInfo>(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,
}
})
}
+28
View File
@@ -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();
+46
View File
@@ -32,6 +32,9 @@ impl DeviceSigner for Signer {
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, 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)));
}