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>> {
|
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) {
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}")]
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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() },
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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?);
|
||||||
s.core.login_with_setup(&setup, &pw).await
|
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
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user