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