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:
@@ -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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user