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
+3
View File
@@ -221,6 +221,9 @@ mod tests {
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>> {
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(internal)
}
fn reset(&self) -> Result<()> {
Err(internal("not supported in this test"))
}
}
fn connect(core: &VaultCore, server: &TestServer) {
+67 -7
View File
@@ -65,6 +65,12 @@ pub enum CoreError {
/// The device is waiting for approval from an enrolled device.
#[error("device approval pending")]
PendingApproval,
/// This device was removed from the account (server 403 `device_revoked`, docs/03 "Removed devices").
/// The core has already reset the device identity (`DeviceSigner::reset`) and kept the local vault data.
/// Show "This device was removed from your account. Set it up again." and the normal sign-in, which enrols
/// the new identity as a new device.
#[error("this device was removed from your account")]
DeviceRevoked,
#[error("network error: {msg}")]
Network { msg: String },
/// The server's TLS key matches none of the pins: possible interception. Never retry silently.
@@ -110,6 +116,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() },
}
@@ -243,6 +250,10 @@ pub trait DeviceSigner: Send + Sync {
fn public_key(&self) -> Vec<u8>;
/// ECDSA P-256 / SHA-256 over `message`, as raw `r || s` (64 bytes, not DER).
fn sign(&self, message: Vec<u8>) -> std::result::Result<Vec<u8>, CoreError>;
/// The server removed this device (docs/03 "Removed devices"): delete the key and the stored device id, then
/// create a new key and a new random UUID. Afterwards `device_id` and `public_key` return the new identity.
/// Called by the core (never loops: at most once per failed call), or through `reset_device_identity`.
fn reset(&self) -> std::result::Result<(), CoreError>;
}
struct SignerAdapter {
@@ -809,6 +820,8 @@ impl VaultCore {
{
return Ok(LoginResult::PendingApproval);
}
// A removed device logged in under a new identity, with a new client.
let api = self.api()?;
let auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
let acct = match local {
@@ -867,9 +880,9 @@ impl VaultCore {
{
return Err(CoreError::PendingApproval);
}
run().await?
self.revoked(run().await)?
}
other => other?,
other => self.revoked(other)?,
};
self.after_sync()?;
Ok(SyncReport {
@@ -887,7 +900,7 @@ impl VaultCore {
impl VaultCore {
/// Devices enrolled on the account, including ones waiting for approval.
pub async fn list_devices(&self) -> Result<Vec<DeviceDto>> {
let devices = self.api()?.devices().await?;
let devices = self.revoked(self.api()?.devices().await)?;
Ok(devices
.into_iter()
.map(|d| DeviceDto {
@@ -905,12 +918,38 @@ impl VaultCore {
/// Approves a pending device (after the user compared `key_fingerprint` on both screens).
pub async fn approve_device(&self, device_id: String) -> Result<()> {
Ok(self.api()?.approve_device(parse_id(&device_id)?).await?)
self.revoked(self.api()?.approve_device(parse_id(&device_id)?).await)
}
/// Revokes a device permanently and ends its sessions.
/// Revokes a device permanently and ends its sessions. The id can never be used again: tell the user "To add
/// it back later, set it up again as a new device."
pub async fn revoke_device(&self, device_id: String) -> Result<()> {
Ok(self.api()?.revoke_device(parse_id(&device_id)?).await?)
self.revoked(self.api()?.revoke_device(parse_id(&device_id)?).await)
}
/// Replaces this device's identity: `DeviceSigner::reset` (new key, new id), then forgets the session. The
/// core does this by itself on `DeviceRevoked`; local vault data is kept. The next login enrols a new device.
pub fn reset_device_identity(&self) -> Result<()> {
let (signer, old, name, platform) = {
let inner = self.lock_inner();
let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?;
(d.signer.inner.clone(), d.signer.id, d.name.clone(), d.platform)
};
signer.reset()?;
let id = Uuid::parse_str(&signer.device_id())
.map_err(|_| CoreError::Invalid { msg: "device id must be a UUID".into() })?;
if id == old {
return Err(internal("the device id did not change on reset"));
}
self.store.delete_meta(META_SESSION)?;
let mut inner = self.lock_inner();
inner.device = Some(Device { signer: Arc::new(SignerAdapter { id, inner: signer }), name, platform });
inner.api = None;
// A removed device signs in again explicitly: the kept OPAQUE input must not silently enrol it.
if let Some(u) = inner.unlocked.as_mut() {
u.auth_input = None;
}
Ok(())
}
/// This device's key fingerprint (same format as `DeviceDto.key_fingerprint`), or `NotConfigured`.
@@ -1033,7 +1072,20 @@ impl VaultCore {
ticket: Option<String>,
) -> Result<LoginResult> {
let device = self.enroll()?;
match api.login_with_ticket(account_id, auth_input, device, totp, ticket).await? {
let outcome = match api.login_with_ticket(account_id, auth_input, device, totp.clone(), ticket.clone()).await {
// docs/03 "Removed devices": start over as a new device. With a ticket in hand (Add a device, scan-to-join)
// retry once at once, as that new device; the refused login did not use the ticket up.
Err(SyncError::DeviceRevoked) => {
self.reset_device_identity()?;
if ticket.is_none() {
return Err(CoreError::DeviceRevoked);
}
let api = self.api()?;
api.login_with_ticket(account_id, auth_input, self.enroll()?, totp, ticket).await?
}
other => other?,
};
match outcome {
LoginOutcome::Ok { session } => {
self.store.put_meta(META_SESSION, session.as_bytes())?;
Ok(LoginResult::Ok)
@@ -1042,6 +1094,14 @@ impl VaultCore {
}
}
/// Passes `r` through; on `DeviceRevoked` from a signed call, resets the device identity first.
fn revoked<T>(&self, r: std::result::Result<T, SyncError>) -> Result<T> {
if let Err(SyncError::DeviceRevoked) = r {
self.reset_device_identity()?;
}
Ok(r?)
}
/// Persists a renewed session and picks the primary vault on a newly enrolled device.
fn after_sync(&self) -> Result<()> {
let api_session = self.lock_inner().api.as_ref().and_then(|a| a.session());
+5 -5
View File
@@ -136,13 +136,13 @@ impl VaultCore {
/// The "Add a device" QR: issues a 10-minute, single-use, unbound enrollment ticket and returns the
/// payload. Show it as a secret view; on Cancel call `revoke_enrollment_ticket`.
pub async fn add_device_payload(&self) -> Result<AddDevicePayload> {
let created = self.api()?.create_ticket(None).await?;
let created = self.revoked(self.api()?.create_ticket(None).await)?;
let payload = self.setup_payload(Some(created.ticket))?.encode()?.to_string();
Ok(AddDevicePayload { payload, ticket_id: created.ticket_id.to_string(), expires_at: created.expires_at })
}
pub async fn revoke_enrollment_ticket(&self, ticket_id: String) -> Result<()> {
Ok(self.api()?.revoke_ticket(parse_id(&ticket_id)?).await?)
self.revoked(self.api()?.revoke_ticket(parse_id(&ticket_id)?).await)
}
/// New device: configures the server and pins from `setup`, then logs in with the master password and the
@@ -243,7 +243,7 @@ impl VaultCore {
}
self.unlocked_snapshot()?;
let keypair = PairingKeypair::generate();
let hello = api.join_hello(&qr.rendezvous_id, keypair.public_bytes().to_vec()).await?;
let hello = self.revoked(api.join_hello(&qr.rendezvous_id, keypair.public_bytes().to_vec()).await)?;
let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?;
let confirm = JoinConfirm {
code: pairing::sas(&rid_bytes, &qr.public_key, &keypair.public_bytes()),
@@ -262,11 +262,11 @@ impl VaultCore {
return Err(invalid("no join to approve"));
};
let api = self.api()?;
let ticket = api.create_ticket(Some(Sha256::digest(&device.p256_pk).to_vec())).await?;
let ticket = self.revoked(api.create_ticket(Some(Sha256::digest(&device.p256_pk).to_vec())).await)?;
let payload = self.setup_payload(Some(ticket.ticket))?.encode()?;
let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?;
let bundle = pairing::seal(&keypair, &qr.public_key, qr.qr_secret.as_ref(), &rid_bytes, payload.as_bytes())?;
Ok(api.join_send(&qr.rendezvous_id, bundle).await?)
self.revoked(api.join_send(&qr.rendezvous_id, bundle).await)
}
}
+3
View File
@@ -173,6 +173,9 @@ impl DeviceSigner for TestSigner {
fn sign(&self, message: Vec<u8>) -> crate::Result<Vec<u8>> {
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(crate::internal)
}
fn reset(&self) -> crate::Result<()> {
Err(crate::internal("not supported in this test"))
}
}
fn connect(core: &VaultCore, server: &TestServer) {
+109 -25
View File
@@ -23,28 +23,42 @@ fn login_item(title: &str, password: &str) -> ItemDto {
}
}
struct TestSigner(SoftwareSigner);
/// A platform signer stand-in whose identity can be reset, like the apps' Keychain / Keystore ones.
struct TestSigner {
key: Mutex<SoftwareSigner>,
resets: std::sync::atomic::AtomicUsize,
}
impl TestSigner {
fn new() -> Self {
Self { key: Mutex::new(SoftwareSigner::generate(Uuid::new_v4())), resets: Default::default() }
}
fn key(&self) -> std::sync::MutexGuard<'_, SoftwareSigner> {
self.key.lock().unwrap()
}
}
impl DeviceSigner for TestSigner {
fn device_id(&self) -> String {
kv_sync::DeviceSigner::device_id(&self.0).to_string()
kv_sync::DeviceSigner::device_id(&*self.key()).to_string()
}
fn public_key(&self) -> Vec<u8> {
kv_sync::DeviceSigner::public_key(&self.0)
kv_sync::DeviceSigner::public_key(&*self.key())
}
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>> {
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(internal)
kv_sync::DeviceSigner::sign(&*self.key(), &message).map_err(internal)
}
fn reset(&self) -> Result<()> {
*self.key() = SoftwareSigner::generate(Uuid::new_v4());
self.resets.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
}
}
fn connect(core: &VaultCore, server: &TestServer) {
core.configure_server(server.url(), vec![]).unwrap();
core.set_device_signer(
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"test".into(),
PlatformDto::Macos,
)
.unwrap();
core.set_device_signer(Arc::new(TestSigner::new()), "test".into(), PlatformDto::Macos).unwrap();
}
#[test]
@@ -260,9 +274,10 @@ async fn pending_approval_and_biometric_session_expiry() {
a.approve_device(pending.device_id.clone()).await.unwrap();
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap();
// Revoking B kills its session.
// Revoking B kills its session; B learns it was removed and resets its identity.
a.revoke_device(pending.device_id.clone()).await.unwrap();
assert!(matches!(b.sync_now().await, Err(CoreError::NotAuthenticated | CoreError::PendingApproval)));
assert!(matches!(b.sync_now().await, Err(CoreError::DeviceRevoked)));
assert_ne!(b.device_key_fingerprint().unwrap(), pending.key_fingerprint);
// A biometric unlock without a stored OPAQUE input cannot renew the session: NotAuthenticated.
let c = VaultCore::new_in_memory();
@@ -407,12 +422,7 @@ async fn add_device_qr_needs_only_the_password_and_no_approval() {
// The new device has nothing configured but its key; the QR and the password are enough.
let b = VaultCore::new_in_memory();
b.set_device_signer(
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"tablet".into(),
PlatformDto::Android,
)
.unwrap();
b.set_device_signer(Arc::new(TestSigner::new()), "tablet".into(), PlatformDto::Android).unwrap();
assert!(matches!(
b.login_with_setup(setup.clone(), "wrong password!!".into()).await,
Err(CoreError::BadCredentials)
@@ -444,13 +454,7 @@ async fn scan_to_join_between_two_clients() {
// Desktop: only knows the server URL.
let desktop = VaultCore::new_in_memory();
desktop
.set_device_signer(
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"Sami's MacBook".into(),
PlatformDto::Macos,
)
.unwrap();
desktop.set_device_signer(Arc::new(TestSigner::new()), "Sami's MacBook".into(), PlatformDto::Macos).unwrap();
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
let session = desktop.join_start(format!("{}/", server.url())).await.unwrap();
assert!(session.qr.starts_with("kestrove://join/v1?") && session.qr.len() < 200);
@@ -555,3 +559,83 @@ async fn scan_to_join_rejects_a_forged_bundle() {
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })), "state cleared");
}
/// docs/03 "Removed devices", end to end: a phone waiting for approval is denied (revoked), keeps its identity
/// across a "reinstall", scans a fresh "Add a device" QR, and gets straight in as a new device with that same
/// ticket. Its old id stays revoked.
#[tokio::test]
async fn revoked_device_resets_and_reenrols_with_the_same_ticket() {
let server = TestServer::start().await;
let (a, sk, id) = phone_with_item(&server).await;
// The phone asks to join and is denied.
let signer = Arc::new(TestSigner::new());
let phone = VaultCore::new_in_memory();
phone.configure_server(server.url(), vec![]).unwrap();
phone.set_device_signer(signer.clone(), "iPhone".into(), PlatformDto::Ios).unwrap();
let old_id = signer.device_id();
assert_eq!(phone.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval);
a.revoke_device(old_id.clone()).await.unwrap();
// Reinstall: a new core, the same platform identity. A wrong password reveals nothing and resets nothing.
let phone = VaultCore::new_in_memory();
phone.set_device_signer(signer.clone(), "iPhone".into(), PlatformDto::Ios).unwrap();
let add = a.add_device_payload().await.unwrap();
let setup = parse_setup_payload(add.payload).unwrap();
assert!(matches!(
phone.login_with_setup(setup.clone(), "wrong password!!".into()).await,
Err(CoreError::BadCredentials)
));
assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 0);
// Right password: device_revoked → reset → one retry with the same ticket → in, no approval needed.
assert_eq!(phone.login_with_setup(setup, PW.into()).await.unwrap(), LoginResult::Ok);
assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 1);
let new_id = signer.device_id();
assert_ne!(new_id, old_id);
phone.sync_now().await.unwrap();
assert_eq!(phone.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
let devices = a.list_devices().await.unwrap();
let old = devices.iter().find(|d| d.device_id == old_id).unwrap();
let new = devices.iter().find(|d| d.device_id == new_id).unwrap();
assert!(old.revoked && !old.approved);
assert!(new.approved && !new.revoked);
assert!(server.state().tickets.values().all(|t| t.used_by.is_some()));
}
/// Without a ticket the core resets and tells the app; the next sign-in waits for approval as a new device,
/// and the local vault data stays.
#[tokio::test]
async fn revoked_device_without_a_ticket_resets_and_asks_to_set_up_again() {
let server = TestServer::start().await;
let (a, sk, id) = phone_with_item(&server).await;
server.state().require_approval = false;
let signer = Arc::new(TestSigner::new());
let b = VaultCore::new_in_memory();
b.configure_server(server.url(), vec![]).unwrap();
b.set_device_signer(signer.clone(), "laptop".into(), PlatformDto::Linux).unwrap();
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap();
let old_id = signer.device_id();
a.revoke_device(old_id.clone()).await.unwrap();
assert!(matches!(b.sync_now().await, Err(CoreError::DeviceRevoked)));
assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 1);
assert!(!b.is_logged_in());
// Still unlocked with its data, but it will not sign in again on its own.
assert_eq!(b.get_item(id.clone()).unwrap().password.as_deref(), Some("pw-a"));
assert!(matches!(b.sync_now().await, Err(CoreError::NotAuthenticated)));
assert_eq!(signer.resets.load(std::sync::atomic::Ordering::SeqCst), 1);
server.state().require_approval = true;
assert_eq!(b.login(PW.into(), sk.clone(), None).await.unwrap(), LoginResult::PendingApproval);
let new_id = signer.device_id();
assert_ne!(new_id, old_id);
a.approve_device(new_id).await.unwrap();
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap();
assert_eq!(b.get_item(id).unwrap().password.as_deref(), Some("pw-a"));
// The old id can never come back.
assert!(matches!(a.approve_device(old_id).await, Err(CoreError::NotFound)));
}
+3
View File
@@ -29,6 +29,9 @@ impl DeviceSigner for Signer {
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, CoreError> {
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| CoreError::Internal { msg: e.to_string() })
}
fn reset(&self) -> Result<(), CoreError> {
Err(CoreError::Internal { msg: "not supported in this test".into() })
}
}
fn connect(core: &VaultCore, url: &str, name: &str) {