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