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>> { fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>> {
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(internal) 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) { 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. /// The device is waiting for approval from an enrolled device.
#[error("device approval pending")] #[error("device approval pending")]
PendingApproval, 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}")] #[error("network error: {msg}")]
Network { msg: String }, Network { msg: String },
/// The server's TLS key matches none of the pins: possible interception. Never retry silently. /// 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::NotAuthenticated => CoreError::NotAuthenticated,
SyncError::BadCredentials => CoreError::BadCredentials, SyncError::BadCredentials => CoreError::BadCredentials,
SyncError::DeviceRevoked => CoreError::DeviceRevoked,
SyncError::NotFound => CoreError::NotFound, SyncError::NotFound => CoreError::NotFound,
other => CoreError::Internal { msg: other.to_string() }, other => CoreError::Internal { msg: other.to_string() },
} }
@@ -243,6 +250,10 @@ pub trait DeviceSigner: Send + Sync {
fn public_key(&self) -> Vec<u8>; fn public_key(&self) -> Vec<u8>;
/// ECDSA P-256 / SHA-256 over `message`, as raw `r || s` (64 bytes, not DER). /// 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>; 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 { struct SignerAdapter {
@@ -809,6 +820,8 @@ impl VaultCore {
{ {
return Ok(LoginResult::PendingApproval); 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 auk = SymKey::from_bytes(Uuid::nil(), *derived.auk);
let acct = match local { let acct = match local {
@@ -867,9 +880,9 @@ impl VaultCore {
{ {
return Err(CoreError::PendingApproval); return Err(CoreError::PendingApproval);
} }
run().await? self.revoked(run().await)?
} }
other => other?, other => self.revoked(other)?,
}; };
self.after_sync()?; self.after_sync()?;
Ok(SyncReport { Ok(SyncReport {
@@ -887,7 +900,7 @@ impl VaultCore {
impl VaultCore { impl VaultCore {
/// Devices enrolled on the account, including ones waiting for approval. /// Devices enrolled on the account, including ones waiting for approval.
pub async fn list_devices(&self) -> Result<Vec<DeviceDto>> { 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 Ok(devices
.into_iter() .into_iter()
.map(|d| DeviceDto { .map(|d| DeviceDto {
@@ -905,12 +918,38 @@ impl VaultCore {
/// Approves a pending device (after the user compared `key_fingerprint` on both screens). /// Approves a pending device (after the user compared `key_fingerprint` on both screens).
pub async fn approve_device(&self, device_id: String) -> Result<()> { 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<()> { 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`. /// This device's key fingerprint (same format as `DeviceDto.key_fingerprint`), or `NotConfigured`.
@@ -1033,7 +1072,20 @@ impl VaultCore {
ticket: Option<String>, ticket: Option<String>,
) -> Result<LoginResult> { ) -> Result<LoginResult> {
let device = self.enroll()?; 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 } => { LoginOutcome::Ok { session } => {
self.store.put_meta(META_SESSION, session.as_bytes())?; self.store.put_meta(META_SESSION, session.as_bytes())?;
Ok(LoginResult::Ok) 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. /// Persists a renewed session and picks the primary vault on a newly enrolled device.
fn after_sync(&self) -> Result<()> { fn after_sync(&self) -> Result<()> {
let api_session = self.lock_inner().api.as_ref().and_then(|a| a.session()); 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 /// 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`. /// payload. Show it as a secret view; on Cancel call `revoke_enrollment_ticket`.
pub async fn add_device_payload(&self) -> Result<AddDevicePayload> { 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(); 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 }) 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<()> { 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 /// 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()?; self.unlocked_snapshot()?;
let keypair = PairingKeypair::generate(); 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 rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?;
let confirm = JoinConfirm { let confirm = JoinConfirm {
code: pairing::sas(&rid_bytes, &qr.public_key, &keypair.public_bytes()), code: pairing::sas(&rid_bytes, &qr.public_key, &keypair.public_bytes()),
@@ -262,11 +262,11 @@ impl VaultCore {
return Err(invalid("no join to approve")); return Err(invalid("no join to approve"));
}; };
let api = self.api()?; 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 payload = self.setup_payload(Some(ticket.ticket))?.encode()?;
let rid_bytes = URL_SAFE_NO_PAD.decode(&qr.rendezvous_id).map_err(internal)?; 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())?; 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>> { fn sign(&self, message: Vec<u8>) -> crate::Result<Vec<u8>> {
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(crate::internal) 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) { 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 { impl DeviceSigner for TestSigner {
fn device_id(&self) -> String { 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> { 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>> { 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) { fn connect(core: &VaultCore, server: &TestServer) {
core.configure_server(server.url(), vec![]).unwrap(); core.configure_server(server.url(), vec![]).unwrap();
core.set_device_signer( core.set_device_signer(Arc::new(TestSigner::new()), "test".into(), PlatformDto::Macos).unwrap();
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"test".into(),
PlatformDto::Macos,
)
.unwrap();
} }
#[test] #[test]
@@ -260,9 +274,10 @@ async fn pending_approval_and_biometric_session_expiry() {
a.approve_device(pending.device_id.clone()).await.unwrap(); a.approve_device(pending.device_id.clone()).await.unwrap();
assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok); assert_eq!(b.login(PW.into(), sk, None).await.unwrap(), LoginResult::Ok);
b.sync_now().await.unwrap(); 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(); 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. // A biometric unlock without a stored OPAQUE input cannot renew the session: NotAuthenticated.
let c = VaultCore::new_in_memory(); 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. // The new device has nothing configured but its key; the QR and the password are enough.
let b = VaultCore::new_in_memory(); let b = VaultCore::new_in_memory();
b.set_device_signer( b.set_device_signer(Arc::new(TestSigner::new()), "tablet".into(), PlatformDto::Android).unwrap();
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"tablet".into(),
PlatformDto::Android,
)
.unwrap();
assert!(matches!( assert!(matches!(
b.login_with_setup(setup.clone(), "wrong password!!".into()).await, b.login_with_setup(setup.clone(), "wrong password!!".into()).await,
Err(CoreError::BadCredentials) Err(CoreError::BadCredentials)
@@ -444,13 +454,7 @@ async fn scan_to_join_between_two_clients() {
// Desktop: only knows the server URL. // Desktop: only knows the server URL.
let desktop = VaultCore::new_in_memory(); let desktop = VaultCore::new_in_memory();
desktop desktop.set_device_signer(Arc::new(TestSigner::new()), "Sami's MacBook".into(), PlatformDto::Macos).unwrap();
.set_device_signer(
Arc::new(TestSigner(SoftwareSigner::generate(Uuid::new_v4()))),
"Sami's MacBook".into(),
PlatformDto::Macos,
)
.unwrap();
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. }))); assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })));
let session = desktop.join_start(format!("{}/", server.url())).await.unwrap(); let session = desktop.join_start(format!("{}/", server.url())).await.unwrap();
assert!(session.qr.starts_with("kestrove://join/v1?") && session.qr.len() < 200); 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 { .. })));
assert!(matches!(desktop.join_poll().await, Err(CoreError::Invalid { .. })), "state cleared"); 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> { 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() }) 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) { fn connect(core: &VaultCore, url: &str, name: &str) {
+6
View File
@@ -20,6 +20,8 @@ use crate::{
}; };
pub const PAGE_LIMIT: u32 = 500; 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. /// Result of an OPAQUE login.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -122,6 +124,10 @@ impl ApiClient {
StatusCode::PRECONDITION_FAILED => SyncError::RevisionConflict, StatusCode::PRECONDITION_FAILED => SyncError::RevisionConflict,
StatusCode::CONFLICT => SyncError::VersionConflict, StatusCode::CONFLICT => SyncError::VersionConflict,
StatusCode::NOT_FOUND => SyncError::NotFound, StatusCode::NOT_FOUND => SyncError::NotFound,
StatusCode::FORBIDDEN if code == DEVICE_REVOKED => {
self.set_session(None);
SyncError::DeviceRevoked
}
StatusCode::UNAUTHORIZED if signed => { StatusCode::UNAUTHORIZED if signed => {
self.set_session(None); self.set_session(None);
SyncError::NotAuthenticated SyncError::NotAuthenticated
+4
View File
@@ -49,6 +49,10 @@ pub enum SyncError {
NotAuthenticated, NotAuthenticated,
#[error("wrong master password or Secret Key")] #[error("wrong master password or Secret Key")]
BadCredentials, 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}")] #[error("authentication failed: {0}")]
Auth(String), Auth(String),
#[error("device signer: {0}")] #[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> { 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 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 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. // 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()); let mut target = format!("{}{}", st.base_url, req.url.path());
if let Some(q) = req.url.query() { if let Some(q) = req.url.query() {
target = format!("{target}?{q}"); 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) let key_id = signing::verify_request(&dev.p256_pk, req.method.as_str(), &target, &req.body, &header)
.map_err(|_| err(401, "bad_signature"))?; .map_err(|_| err(401, "bad_signature"))?;
if key_id != device || !dev.approved { 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 login = ServerLogin::<Suite>::deserialize(&state).map_err(|_| err(401, "unauthorized"))?;
let ke3 = CredentialFinalization::deserialize(&r.ke3).map_err(|_| err(401, "unauthorized"))?; let ke3 = CredentialFinalization::deserialize(&r.ke3).map_err(|_| err(401, "unauthorized"))?;
login.finish(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 { if let Some(secret) = &r.ticket {
redeem_ticket(st, secret, device); 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. // The account endpoint gives a new device what it needs to unlock.
let info = c.api.account().await.unwrap(); let info = c.api.account().await.unwrap();
assert_eq!(info.sig_pk, a.keyset.sig_public().to_vec()); 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 // ---------------------------------------------------------------- TLS pinning
+14 -6
View File
@@ -80,7 +80,7 @@ parseSetupPayload(s): SetupInfo; // {serverUrl, s
Errors reject with an `Error` whose `name` is `"CoreError"` and whose `code` is one of kv-ffi's Errors reject with an `Error` whose `name` is `"CoreError"` and whose `code` is one of kv-ffi's
`CoreError` cases (`Locked`, `NoAccount`, `BadCredentials`, `Invalid`, `NotFound`, `Internal`, `CoreError` cases (`Locked`, `NoAccount`, `BadCredentials`, `Invalid`, `NotFound`, `Internal`,
`NotConfigured`, `NotAuthenticated`, `PendingApproval`, `Network`, `Server` + `status`, `NotConfigured`, `NotAuthenticated`, `PendingApproval`, `DeviceRevoked`, `Network`, `Server` + `status`,
`IntegrityAlarm` + `vaultId`/`reason`), plus `Busy` (a sync is already running) and, from the worker `IntegrityAlarm` + `vaultId`/`reason`), plus `Busy` (a sync is already running) and, from the worker
client, `InUse` (another tab has the vault open). client, `InUse` (another tab has the vault open).
@@ -125,14 +125,22 @@ two replicas overwriting each other.
`NotAuthenticated` (ask for the password and `login` again), `PendingApproval`, `NotAuthenticated` (ask for the password and `login` again), `PendingApproval`,
`IntegrityAlarm` (stop and alert: the server rolled back or tampered with a vault) and `IntegrityAlarm` (stop and alert: the server rolled back or tampered with a vault) and
`restoredItemIds` (tell the user an edit brought a deleted item back). `restoredItemIds` (tell the user an edit brought a deleted item back).
7. **Storage.** Call `navigator.storage.persist()`. If the browser evicts IndexedDB, unsynced edits 7. **Removed browser** (kestrove-spec docs/03 "Removed devices"). `DeviceRevoked` from any call means
the server removed this browser's device id for good. The vault has already replaced the WebCrypto
key and device id in IndexedDB (`deviceId()` changes), dropped the session and kept the local data.
`loginWithSetup` with a ticket retries once by itself as the new device and normally resolves `"ok"`.
Otherwise show "This device was removed from your account. Set it up again." and the sign-in: the
next `login` enrols the new identity (`"pendingApproval"`). `resetDeviceIdentity()` does the same
reset on demand. When the user removes a device, warn "To add it back later, set it up again as a new
device."
8. **Storage.** Call `navigator.storage.persist()`. If the browser evicts IndexedDB, unsynced edits
and the device key are lost and the browser must be approved again. and the device key are lost and the browser must be approved again.
8. **Sign out of this browser:** `logout()` (ends the server session), then `wipe()` (removes the 9. **Sign out of this browser:** `logout()` (ends the server session), then `wipe()` (removes the
replica and device key; the next login is a new device). replica and device key; the next login is a new device).
9. **Import/export:** pass `new Uint8Array(await file.arrayBuffer())`; `exportEncrypted` resolves with 10. **Import/export:** pass `new Uint8Array(await file.arrayBuffer())`; `exportEncrypted` resolves with
the `.kvex` bytes for a `Blob` download. the `.kvex` bytes for a `Blob` download.
10. `itemsForUrl` / `matchUrl` match the same host only (no Public Suffix List in this build). 11. `itemsForUrl` / `matchUrl` match the same host only (no Public Suffix List in this build).
11. **Onboarding** (docs/10). Same flows and names as kv-ffi; `expiresAt` is Unix **seconds** 12. **Onboarding** (docs/10). Same flows and names as kv-ffi; `expiresAt` is Unix **seconds**
and codes look like `"123 456"`, as in kv-ffi. and codes look like `"123 456"`, as in kv-ffi.
* *Sign in with a setup code*: `parseSetupPayload(text)` (scanned or pasted; it is a secret, * *Sign in with a setup code*: `parseSetupPayload(text)` (scanned or pasted; it is a secret,
so never put it in a URL or register `kestrove://` as a handler), then so never put it in a URL or register `kestrove://` as a handler), then
+14
View File
@@ -463,6 +463,20 @@ impl WebCore {
self.lock_inner().device.as_ref().map(|d| d.signer.device_id()) self.lock_inner().device.as_ref().map(|d| d.signer.device_id())
} }
/// After `DeviceRevoked` (docs/03 "Removed devices"): forgets the session and the kept OPAQUE input, so that
/// the new identity the caller installs next (`set_device_signer`) is enrolled only by an explicit login.
/// Local vault data stays. The browser binding calls this and replaces the WebCrypto key; it then retries a
/// ticket login once.
pub fn forget_revoked_device(&self) -> Result<()> {
self.store.delete_meta(META_SESSION)?;
let mut inner = self.lock_inner();
inner.api = None;
if let Some(u) = inner.unlocked.as_mut() {
u.auth_input = None;
}
Ok(())
}
pub fn device_key_fingerprint(&self) -> Result<String> { pub fn device_key_fingerprint(&self) -> Result<String> {
let inner = self.lock_inner(); let inner = self.lock_inner();
let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?; let d = inner.device.as_ref().ok_or(CoreError::NotConfigured)?;
+7
View File
@@ -28,6 +28,11 @@ pub enum CoreError {
/// This browser is waiting for approval from an enrolled device. /// This browser is waiting for approval from an enrolled device.
#[error("device approval pending")] #[error("device approval pending")]
PendingApproval, PendingApproval,
/// This browser was removed from the account (server 403 `device_revoked`, docs/03 "Removed devices"). The
/// vault has already replaced its device key and id and kept the local data. Show "This device was removed
/// from your account. Set it up again." and the normal sign-in, which enrols it as a new device.
#[error("this device was removed from your account")]
DeviceRevoked,
#[error("network error: {msg}")] #[error("network error: {msg}")]
Network { msg: String }, Network { msg: String },
/// Native only (TLS pinning); never raised in the browser. Kept so the code set matches kv-ffi. /// Native only (TLS pinning); never raised in the browser. Kept so the code set matches kv-ffi.
@@ -55,6 +60,7 @@ impl CoreError {
CoreError::NotConfigured => "NotConfigured", CoreError::NotConfigured => "NotConfigured",
CoreError::NotAuthenticated => "NotAuthenticated", CoreError::NotAuthenticated => "NotAuthenticated",
CoreError::PendingApproval => "PendingApproval", CoreError::PendingApproval => "PendingApproval",
CoreError::DeviceRevoked => "DeviceRevoked",
CoreError::Network { .. } => "Network", CoreError::Network { .. } => "Network",
CoreError::PinMismatch => "PinMismatch", CoreError::PinMismatch => "PinMismatch",
CoreError::Server { .. } => "Server", CoreError::Server { .. } => "Server",
@@ -103,6 +109,7 @@ impl From<SyncError> for CoreError {
} }
SyncError::NotAuthenticated => CoreError::NotAuthenticated, SyncError::NotAuthenticated => CoreError::NotAuthenticated,
SyncError::BadCredentials => CoreError::BadCredentials, SyncError::BadCredentials => CoreError::BadCredentials,
SyncError::DeviceRevoked => CoreError::DeviceRevoked,
SyncError::NotFound => CoreError::NotFound, SyncError::NotFound => CoreError::NotFound,
other => CoreError::Internal { msg: other.to_string() }, other => CoreError::Internal { msg: other.to_string() },
} }
+42 -2
View File
@@ -171,7 +171,8 @@ export interface JoinConfirm {
export type CoreErrorCode = export type CoreErrorCode =
| "Locked" | "NoAccount" | "BadCredentials" | "Invalid" | "NotFound" | "Internal" | "NotConfigured" | "Locked" | "NoAccount" | "BadCredentials" | "Invalid" | "NotFound" | "Internal" | "NotConfigured"
| "NotAuthenticated" | "PendingApproval" | "Network" | "PinMismatch" | "Server" | "IntegrityAlarm" | "Busy"; | "NotAuthenticated" | "PendingApproval" | "DeviceRevoked" | "Network" | "PinMismatch" | "Server" | "IntegrityAlarm"
| "Busy";
/** What every rejected promise carries. `status` is set for "Server"; `vaultId`/`reason` for "IntegrityAlarm". */ /** What every rejected promise carries. `status` is set for "Server"; `vaultId`/`reason` for "IntegrityAlarm". */
export interface CoreError extends Error { export interface CoreError extends Error {
@@ -310,6 +311,23 @@ impl Shared {
let d = self.device.borrow(); let d = self.device.borrow();
self.core.set_device_signer(d.signer.clone(), &d.name, d.platform); self.core.set_device_signer(d.signer.clone(), &d.name, d.platform);
} }
/// docs/03 "Removed devices": the server removed this browser. Replace the WebCrypto key and the device id
/// (same name and platform) in IndexedDB, forget the session, keep the vault data.
async fn reset_device(&self) -> Result<()> {
self.core.forget_revoked_device()?;
let mut fresh = new_device().await.map_err(js_internal)?;
{
let old = self.device.borrow();
old.signer.forget();
fresh.name = old.name.clone();
fresh.platform = old.platform;
}
*self.device.borrow_mut() = fresh;
self.install_device();
self.save_device().await?;
self.persist().await
}
} }
/// The vault in this browser. Create it with `VaultCore.open()` (IndexedDB) or /// The vault in this browser. Create it with `VaultCore.open()` (IndexedDB) or
@@ -376,7 +394,14 @@ impl VaultCore {
Fut: Future<Output = Result<T>> + 'static, Fut: Future<Output = Result<T>> + 'static,
{ {
let s = self.shared.clone(); let s = self.shared.clone();
future_to_promise(async move { op(s).await.map_err(|e| core_error(&e)).and_then(|v| to_js(&v)) }) future_to_promise(async move {
let r = op(s.clone()).await;
// Any call the server refused as a removed device starts this browser over as a new one.
if let Err(CoreError::DeviceRevoked) = r {
s.reset_device().await.map_err(|e| core_error(&e))?;
}
r.map_err(|e| core_error(&e)).and_then(|v| to_js(&v))
})
} }
/// Like `run`, then flushes changed entries to IndexedDB, even when `op` failed part-way /// Like `run`, then flushes changed entries to IndexedDB, even when `op` failed part-way
@@ -599,6 +624,13 @@ impl VaultCore {
}) })
} }
/// Replaces this browser's device key and id (the vault does it by itself on `DeviceRevoked`). Local data
/// stays; the next login enrols a new device.
#[wasm_bindgen(js_name = resetDeviceIdentity, unchecked_return_type = "Promise<void>")]
pub fn reset_device_identity(&self) -> Promise {
self.run(|s| async move { s.reset_device().await })
}
#[wasm_bindgen(js_name = deviceId, unchecked_return_type = "Promise<string>")] #[wasm_bindgen(js_name = deviceId, unchecked_return_type = "Promise<string>")]
pub fn device_id(&self) -> Promise { pub fn device_id(&self) -> Promise {
self.run(|s| async move { s.core.device_id().map(|d| d.to_string()).ok_or(CoreError::NotConfigured) }) self.run(|s| async move { s.core.device_id().map(|d| d.to_string()).ok_or(CoreError::NotConfigured) })
@@ -696,7 +728,15 @@ impl VaultCore {
let (setup, pw) = (from_js::<SetupInfo>(setup), zeroize::Zeroizing::new(master_password)); let (setup, pw) = (from_js::<SetupInfo>(setup), zeroize::Zeroizing::new(master_password));
self.write(move |s| async move { self.write(move |s| async move {
let setup = zeroize::Zeroizing::new(setup?); let setup = zeroize::Zeroizing::new(setup?);
match s.core.login_with_setup(&setup, &pw).await {
// A removed browser holding a ticket: new key and id, then the same login once more, as that new
// device (the refused login did not use the ticket up).
Err(CoreError::DeviceRevoked) if setup.ticket.is_some() => {
s.reset_device().await?;
s.core.login_with_setup(&setup, &pw).await s.core.login_with_setup(&setup, &pw).await
}
other => other,
}
}) })
} }
+28
View File
@@ -123,6 +123,34 @@ async fn account_lifecycle_persists_in_indexeddb() {
db.close(); db.close();
} }
/// docs/03 "Removed devices": a reset replaces the WebCrypto key and device id in IndexedDB (same name and
/// platform), keeps the account and items, and survives a reload.
#[wasm_bindgen_test]
async fn reset_device_identity_replaces_the_stored_key_and_keeps_the_vault() {
let db_name = format!("kv-test-{}", Uuid::new_v4());
let core = open_db(&db_name).await;
ok(core.set_device("Firefox on Mac".into(), Some("macos".into()))).await;
ok(core.create_account(PW.into())).await;
let id = ok(core.save_item(js(login("Mail", "kept-after-reset")))).await.as_string().unwrap();
let (old_id, old_fp) =
(ok(core.device_id()).await.as_string().unwrap(), ok(core.device_key_fingerprint()).await.as_string().unwrap());
ok(core.reset_device_identity()).await;
let new_id = ok(core.device_id()).await.as_string().unwrap();
assert_ne!(new_id, old_id);
assert_ne!(ok(core.device_key_fingerprint()).await.as_string().unwrap(), old_fp);
let item: ItemDto = de(ok(core.get_item(id.clone())).await);
assert_eq!(item.password.as_deref(), Some("kept-after-reset"));
let db = core.shared.db.clone().unwrap();
db.close();
drop(core);
let core = open_db(&db_name).await;
assert_eq!(ok(core.device_id()).await.as_string().unwrap(), new_id);
let d = core.shared.device.borrow();
assert_eq!((d.name.as_str(), d.platform), ("Firefox on Mac", Platform::Macos));
}
#[wasm_bindgen_test] #[wasm_bindgen_test]
async fn webcrypto_device_key_signs_rfc9421_requests() { async fn webcrypto_device_key_signs_rfc9421_requests() {
let (signer, _private) = WebCryptoSigner::generate().await.unwrap(); let (signer, _private) = WebCryptoSigner::generate().await.unwrap();
+46
View File
@@ -32,6 +32,9 @@ impl DeviceSigner for Signer {
fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, kv_ffi::CoreError> { fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, kv_ffi::CoreError> {
kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| kv_ffi::CoreError::Internal { msg: e.to_string() }) kv_sync::DeviceSigner::sign(&self.0, &message).map_err(|e| kv_ffi::CoreError::Internal { msg: e.to_string() })
} }
fn reset(&self) -> Result<(), kv_ffi::CoreError> {
Err(kv_ffi::CoreError::Internal { msg: "not supported in this test".into() })
}
} }
fn web_item(title: &str, password: &str) -> ItemDto { fn web_item(title: &str, password: &str) -> ItemDto {
@@ -371,3 +374,46 @@ async fn web_vault_join_refuses_a_swapped_approver() {
web.join_cancel(); web.join_cancel();
assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. }))); assert!(matches!(web.join_poll().await, Err(CoreError::Invalid { .. })));
} }
/// docs/03 "Removed devices" for the web core: a removed browser gets `DeviceRevoked` (only with the right
/// password), forgets its session, and the same "Add a device" ticket gets it back in under a new key and id
/// (what the browser binding does: `forget_revoked_device`, new WebCrypto key, one retry).
#[tokio::test]
async fn removed_web_vault_reenrols_with_the_same_ticket() {
let server = TestServer::start().await;
let (app, _sk, id) = app_with_item(&server).await;
let add = app.add_device_payload().await.unwrap();
let setup: kv_wasm::dto::SetupInfo = {
let i = kv_ffi::parse_setup_payload(add.payload.clone()).unwrap();
kv_wasm::dto::SetupInfo {
server_url: i.server_url,
spki_pins: i.spki_pins,
account_id: i.account_id,
secret_key: i.secret_key,
ticket: i.ticket,
}
};
// The browser enrolled once (pending), then was denied.
let (store, web) = new_web(&server);
let old = web.device_id().unwrap();
let no_ticket = kv_wasm::dto::SetupInfo { ticket: None, ..setup.clone() };
assert_eq!(web.login_with_setup(&no_ticket, PW).await.unwrap(), LoginResult::PendingApproval);
app.revoke_device(old.to_string()).await.unwrap();
assert!(matches!(web.login_with_setup(&setup, "wrong password!!").await, Err(CoreError::BadCredentials)));
assert!(matches!(web.login_with_setup(&setup, PW).await, Err(CoreError::DeviceRevoked)));
web.forget_revoked_device().unwrap();
web.set_device_signer(Arc::new(SoftwareSigner::generate(Uuid::new_v4())), "Web vault", Platform::Macos);
assert_ne!(web.device_id().unwrap(), old);
assert_eq!(web.login_with_setup(&setup, PW).await.unwrap(), LoginResult::Ok);
web.sync_now().await.unwrap();
assert_eq!(web.get_item(&id).unwrap().password.as_deref(), Some("from the app"));
drop(store);
// Signed in, then removed: sync says so; the old id never comes back.
app.revoke_device(web.device_id().unwrap().to_string()).await.unwrap();
assert!(matches!(web.sync_now().await, Err(CoreError::DeviceRevoked)));
web.forget_revoked_device().unwrap();
assert!(matches!(app.approve_device(old.to_string()).await, Err(kv_ffi::CoreError::NotFound)));
}