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
+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,
}
})
}