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