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
+6
View File
@@ -20,6 +20,8 @@ use crate::{
};
pub const PAGE_LIMIT: u32 = 500;
/// Error code of the 403 for a removed device (docs/03 "Removed devices").
pub const DEVICE_REVOKED: &str = "device_revoked";
/// Result of an OPAQUE login.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -122,6 +124,10 @@ impl ApiClient {
StatusCode::PRECONDITION_FAILED => SyncError::RevisionConflict,
StatusCode::CONFLICT => SyncError::VersionConflict,
StatusCode::NOT_FOUND => SyncError::NotFound,
StatusCode::FORBIDDEN if code == DEVICE_REVOKED => {
self.set_session(None);
SyncError::DeviceRevoked
}
StatusCode::UNAUTHORIZED if signed => {
self.set_session(None);
SyncError::NotAuthenticated
+4
View File
@@ -49,6 +49,10 @@ pub enum SyncError {
NotAuthenticated,
#[error("wrong master password or Secret Key")]
BadCredentials,
/// HTTP 403 `device_revoked` (docs/03 "Removed devices"): this device id was removed from the account and
/// can never be used again. The client must reset its device identity and enrol as a new device.
#[error("this device was removed from the account")]
DeviceRevoked,
#[error("authentication failed: {0}")]
Auth(String),
#[error("device signer: {0}")]
+12 -2
View File
@@ -195,13 +195,19 @@ impl Respond for Handler {
fn authenticate(st: &State, req: &Request) -> Result<Uuid, ResponseTemplate> {
let header = |name: &str| req.headers.get(name).and_then(|v| v.to_str().ok()).map(str::to_string);
let session = header(signing::HDR_SESSION).ok_or_else(|| err(401, "unauthorized"))?;
let device = *st.sessions.get(&session).ok_or_else(|| err(401, "unauthorized"))?;
let dev = st.devices.get(&device).ok_or_else(|| err(401, "unauthorized"))?;
// wiremock rebuilds `req.url` without the port; the signed @target-uri is the URL the client used.
let mut target = format!("{}{}", st.base_url, req.url.path());
if let Some(q) = req.url.query() {
target = format!("{target}?{q}");
}
let Some(&device) = st.sessions.get(&session) else {
// docs/03 "Removed devices": a valid signature by a revoked device's key gets 403 device_revoked.
let revoked_signer = st.devices.iter().filter(|(_, d)| d.revoked).any(|(id, d)| {
signing::verify_request(&d.p256_pk, req.method.as_str(), &target, &req.body, &header).ok() == Some(*id)
});
return Err(if revoked_signer { err(403, "device_revoked") } else { err(401, "unauthorized") });
};
let dev = st.devices.get(&device).ok_or_else(|| err(401, "unauthorized"))?;
let key_id = signing::verify_request(&dev.p256_pk, req.method.as_str(), &target, &req.body, &header)
.map_err(|_| err(401, "bad_signature"))?;
if key_id != device || !dev.approved {
@@ -293,6 +299,10 @@ fn handle(st: &mut State, req: &Request) -> Result<ResponseTemplate, ResponseTem
let login = ServerLogin::<Suite>::deserialize(&state).map_err(|_| err(401, "unauthorized"))?;
let ke3 = CredentialFinalization::deserialize(&r.ke3).map_err(|_| err(401, "unauthorized"))?;
login.finish(ke3).map_err(|_| err(401, "unauthorized"))?;
// Checked after OPAQUE and before the ticket, which therefore stays unused (docs/03 "Removed devices").
if st.devices.get(&device).is_some_and(|d| d.revoked) {
return Err(err(403, "device_revoked"));
}
if let Some(secret) = &r.ticket {
redeem_ticket(st, secret, device);
}
+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