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
+17
View File
@@ -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