fix(sync): secure encrypted snapshot lifecycle
This commit is contained in:
@@ -4,18 +4,27 @@ use std::{
|
||||
};
|
||||
|
||||
use ely_sync_client::{
|
||||
ApiClientConfig, BearerToken, BearerTokenStore, DeviceIdentity, SnapshotPayload,
|
||||
SnapshotUploadRequest, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument,
|
||||
AccountKey, ApiClientConfig, AuthenticatedSnapshotHead, BearerToken, BearerTokenStore,
|
||||
DeviceIdentity, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, SnapshotDownloadResult,
|
||||
SnapshotPayload, SnapshotUploadRequest, SnapshotUploadResult, SyncApiClient, SyncClientError,
|
||||
SyncLatestSnapshotDocument,
|
||||
};
|
||||
|
||||
use crate::state::BrowserCore;
|
||||
use crate::sync_records::{SNAPSHOT_SCHEMA_REV, SyncSnapshotBody};
|
||||
|
||||
mod concurrency;
|
||||
mod device_management;
|
||||
mod vault_management;
|
||||
|
||||
use concurrency::{conflict_head, ensure_remote_generation_is_available};
|
||||
|
||||
/// Per-profile sync engine for device identity, bearer-token storage, and snapshot IO.
|
||||
#[derive(Debug)]
|
||||
pub struct SyncEngine {
|
||||
api_config: ApiClientConfig,
|
||||
bearer_store: BearerTokenStore,
|
||||
account_key_lock_dir: PathBuf,
|
||||
identity: DeviceIdentity,
|
||||
last_outcome: Option<SyncOutcome>,
|
||||
}
|
||||
@@ -30,12 +39,18 @@ impl SyncEngine {
|
||||
platform: impl Into<String>,
|
||||
) -> Result<Self, SyncClientError> {
|
||||
let sync_dir = profile_data_dir.join("sync");
|
||||
let account_key_lock_dir = profile_data_dir
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.unwrap_or(profile_data_dir)
|
||||
.join(".sync-key-locks");
|
||||
let identity =
|
||||
DeviceIdentity::load_or_create(&sync_dir.join("device.json"), device_name, platform)?;
|
||||
let bearer_store = BearerTokenStore::new(sync_dir.join("bearer.token"));
|
||||
Ok(Self {
|
||||
api_config: ApiClientConfig::production(),
|
||||
bearer_store,
|
||||
account_key_lock_dir,
|
||||
identity,
|
||||
last_outcome: None,
|
||||
})
|
||||
@@ -71,40 +86,42 @@ impl SyncEngine {
|
||||
self.bearer_store.load().map(|token| token.is_some())
|
||||
}
|
||||
|
||||
/// Run the snapshot sync plan for a pre-serialised local payload.
|
||||
/// The engine registers the device, checks the worker's latest
|
||||
/// snapshot, downloads a newer remote payload when another device
|
||||
/// wrote one, and uploads when the local payload is ready to win.
|
||||
/// Reconcile a pre-serialised local payload with the authenticated global snapshot head.
|
||||
pub fn sync_bytes(&mut self, bytes: Vec<u8>) -> Result<SyncOutcome, SyncClientError> {
|
||||
let Some(bearer) = self.bearer_store.load()? else {
|
||||
let outcome = SyncOutcome::SignedOut;
|
||||
self.last_outcome = Some(outcome.clone());
|
||||
return Ok(outcome);
|
||||
};
|
||||
let payload = SnapshotPayload::new(bytes)?;
|
||||
let client = SyncApiClient::new(self.api_config.clone(), bearer)?;
|
||||
let Some(client) = self.approved_client(client)? else {
|
||||
let Some((client, user_id)) = self.approved_client(client)? else {
|
||||
let outcome =
|
||||
SyncOutcome::AwaitingDeviceApproval { device_id: self.identity.device_id.clone() };
|
||||
self.last_outcome = Some(outcome.clone());
|
||||
return Ok(outcome);
|
||||
};
|
||||
|
||||
let vault = self.resolve_vault(&client, &user_id)?;
|
||||
let status = client.sync_status()?;
|
||||
let outcome = match status.snapshots.latest {
|
||||
Some(latest) if latest.payload_hash == payload.payload_hash() => {
|
||||
SyncOutcome::AlreadyCurrent {
|
||||
snapshot_id: latest.snapshot_id,
|
||||
logical_clock: latest.logical_clock,
|
||||
payload_bytes: latest.size_bytes,
|
||||
device_id: latest.device_id,
|
||||
validate_sync_status(&status, &user_id, &self.identity.device_id)?;
|
||||
let outcome = match status.snapshots.head {
|
||||
Some(head) => {
|
||||
let remote = self.download_remote_snapshot(&client, &user_id, &vault, head)?;
|
||||
if remote.bytes == bytes && remote.merge_base.vault_generation() == vault.generation
|
||||
{
|
||||
SyncOutcome::AlreadyCurrent {
|
||||
snapshot_id: remote.merge_base.snapshot_id().to_string(),
|
||||
logical_clock: remote.merge_base.logical_clock(),
|
||||
payload_bytes: remote.merge_base.size_bytes(),
|
||||
device_id: remote.merge_base.device_id().to_string(),
|
||||
}
|
||||
} else if remote.bytes == bytes {
|
||||
self.upload_payload(&client, &user_id, &vault, bytes, Some(&remote.merge_base))?
|
||||
} else {
|
||||
remote.into_outcome(false)
|
||||
}
|
||||
}
|
||||
Some(latest) if latest.device_id != self.identity.device_id => {
|
||||
self.download_remote_snapshot(&client, latest)?
|
||||
}
|
||||
Some(latest) => self.upload_payload(&client, payload, latest.logical_clock)?,
|
||||
None => self.upload_payload(&client, payload, 0)?,
|
||||
None => self.upload_payload(&client, &user_id, &vault, bytes, None)?,
|
||||
};
|
||||
self.last_outcome = Some(outcome.clone());
|
||||
Ok(outcome)
|
||||
@@ -116,22 +133,22 @@ impl SyncEngine {
|
||||
pub fn upload_merged_bytes(
|
||||
&mut self,
|
||||
bytes: Vec<u8>,
|
||||
logical_clock_floor: u64,
|
||||
merge_base: AuthenticatedSnapshotHead,
|
||||
) -> Result<SyncOutcome, SyncClientError> {
|
||||
let Some(bearer) = self.bearer_store.load()? else {
|
||||
let outcome = SyncOutcome::SignedOut;
|
||||
self.last_outcome = Some(outcome.clone());
|
||||
return Ok(outcome);
|
||||
};
|
||||
let payload = SnapshotPayload::new(bytes)?;
|
||||
let client = SyncApiClient::new(self.api_config.clone(), bearer)?;
|
||||
let Some(client) = self.approved_client(client)? else {
|
||||
let Some((client, user_id)) = self.approved_client(client)? else {
|
||||
let outcome =
|
||||
SyncOutcome::AwaitingDeviceApproval { device_id: self.identity.device_id.clone() };
|
||||
self.last_outcome = Some(outcome.clone());
|
||||
return Ok(outcome);
|
||||
};
|
||||
let outcome = self.upload_payload(&client, payload, logical_clock_floor)?;
|
||||
let vault = self.resolve_vault(&client, &user_id)?;
|
||||
let outcome = self.upload_payload(&client, &user_id, &vault, bytes, Some(&merge_base))?;
|
||||
self.last_outcome = Some(outcome.clone());
|
||||
Ok(outcome)
|
||||
}
|
||||
@@ -139,13 +156,10 @@ impl SyncEngine {
|
||||
fn approved_client(
|
||||
&self,
|
||||
client: SyncApiClient,
|
||||
) -> Result<Option<SyncApiClient>, SyncClientError> {
|
||||
let registration = client.register_device(
|
||||
&self.identity,
|
||||
&device_registration_idempotency_key(&self.identity),
|
||||
)?;
|
||||
) -> Result<Option<(SyncApiClient, String)>, SyncClientError> {
|
||||
let (client, registration) = self.registered_client(client)?;
|
||||
if registration.device.is_approved() {
|
||||
return Ok(Some(client));
|
||||
return Ok(Some((client, registration.user_id)));
|
||||
}
|
||||
if registration.device.approval_status == "pending" {
|
||||
return Ok(None);
|
||||
@@ -156,44 +170,173 @@ impl SyncEngine {
|
||||
})
|
||||
}
|
||||
|
||||
fn registered_client(
|
||||
&self,
|
||||
client: SyncApiClient,
|
||||
) -> Result<(SyncApiClient, ely_sync_client::client::DeviceRecordDocument), SyncClientError>
|
||||
{
|
||||
let idempotency_key = device_registration_idempotency_key(&self.identity);
|
||||
let registration = match client.register_device(&self.identity, &idempotency_key) {
|
||||
Ok(registration) => registration,
|
||||
Err(SyncClientError::HttpStatus { status: 409, .. }) => {
|
||||
let rebound = client.rebind_device(&self.identity)?;
|
||||
let registration = client.register_device(&self.identity, &idempotency_key)?;
|
||||
if registration.user_id != rebound.user_id {
|
||||
return Err(SyncClientError::DeviceTrust {
|
||||
reason: "device rebind account does not match registration",
|
||||
});
|
||||
}
|
||||
registration
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
Ok((client, registration))
|
||||
}
|
||||
|
||||
fn upload_payload(
|
||||
&self,
|
||||
client: &SyncApiClient,
|
||||
payload: SnapshotPayload,
|
||||
logical_clock_floor: u64,
|
||||
user_id: &str,
|
||||
vault: &ResolvedVault,
|
||||
bytes: Vec<u8>,
|
||||
base: Option<&AuthenticatedSnapshotHead>,
|
||||
) -> Result<SyncOutcome, SyncClientError> {
|
||||
let logical_clock_floor = base.map_or(0, AuthenticatedSnapshotHead::logical_clock);
|
||||
let logical_clock = current_logical_clock().max(logical_clock_floor.saturating_add(1));
|
||||
let snapshot_id = snapshot_id_for_user(&self.identity);
|
||||
let request = SnapshotUploadRequest::new(
|
||||
&snapshot_id,
|
||||
self.api_config.region(),
|
||||
SNAPSHOT_SCHEMA_REV,
|
||||
let head_revision = match base {
|
||||
Some(base) => base.next_revision()?,
|
||||
None => 1,
|
||||
};
|
||||
let context = SnapshotCryptoContext {
|
||||
user_id,
|
||||
vault_generation: vault.generation,
|
||||
snapshot_id: &snapshot_id,
|
||||
schema_rev: SNAPSHOT_SCHEMA_REV,
|
||||
logical_clock,
|
||||
device_id: &self.identity.device_id,
|
||||
head_revision,
|
||||
base_head: base.map(AuthenticatedSnapshotHead::head_ref),
|
||||
};
|
||||
let encrypted = vault.account_key.encrypt(&context, &bytes)?;
|
||||
let payload = SnapshotPayload::new(encrypted.bytes().to_vec())?;
|
||||
let request = SnapshotUploadRequest::new(
|
||||
self.api_config.region(),
|
||||
&context,
|
||||
base,
|
||||
&encrypted,
|
||||
&payload,
|
||||
);
|
||||
let document = client.upload_snapshot(&request)?;
|
||||
Ok(SyncOutcome::Uploaded {
|
||||
snapshot_id: document.snapshot.snapshot_id,
|
||||
logical_clock: document.snapshot.logical_clock,
|
||||
payload_bytes: document.snapshot.size_bytes,
|
||||
device_id: document.device_id,
|
||||
})
|
||||
)?;
|
||||
match client.upload_snapshot(&request)? {
|
||||
SnapshotUploadResult::Committed(document) => {
|
||||
if document.version != 3
|
||||
|| document.user_id != user_id
|
||||
|| document.device_id != self.identity.device_id
|
||||
|| document.snapshot.snapshot_id != snapshot_id
|
||||
|| document.snapshot.head_revision != head_revision
|
||||
|| document.snapshot.base_head.as_ref()
|
||||
!= base.map(AuthenticatedSnapshotHead::head_ref)
|
||||
|| document.snapshot.payload_hash != payload.payload_hash()
|
||||
|| document.snapshot.encryption_version != SNAPSHOT_ENCRYPTION_VERSION
|
||||
|| document.snapshot.key_id != vault.account_key.key_id()
|
||||
|| document.snapshot.vault_generation != vault.generation
|
||||
|| document.snapshot.content_hash != encrypted.content_hash()
|
||||
|| document.snapshot.schema_rev != SNAPSHOT_SCHEMA_REV
|
||||
|| document.snapshot.logical_clock != logical_clock
|
||||
|| document.snapshot.size_bytes
|
||||
!= u64::try_from(payload.bytes().len()).map_err(|_| {
|
||||
SyncClientError::SnapshotEncryption {
|
||||
reason: "snapshot payload size is invalid",
|
||||
}
|
||||
})?
|
||||
{
|
||||
return Err(SyncClientError::SnapshotEncryption {
|
||||
reason: "snapshot upload response does not match request",
|
||||
});
|
||||
}
|
||||
Ok(SyncOutcome::Uploaded {
|
||||
snapshot_id: document.snapshot.snapshot_id,
|
||||
logical_clock: document.snapshot.logical_clock,
|
||||
payload_bytes: document.snapshot.size_bytes,
|
||||
device_id: document.device_id,
|
||||
})
|
||||
}
|
||||
SnapshotUploadResult::Conflict(conflict) => {
|
||||
let head = conflict_head(conflict)?;
|
||||
self.download_remote_snapshot(client, user_id, vault, head)
|
||||
.map(|remote| remote.into_outcome(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn download_remote_snapshot(
|
||||
&self,
|
||||
client: &SyncApiClient,
|
||||
latest: SyncLatestSnapshotDocument,
|
||||
) -> Result<SyncOutcome, SyncClientError> {
|
||||
let download = client.download_snapshot(&latest.snapshot_id)?;
|
||||
let payload = download.payload()?;
|
||||
Ok(SyncOutcome::RemoteSnapshot {
|
||||
snapshot_id: latest.snapshot_id,
|
||||
logical_clock: latest.logical_clock,
|
||||
payload_bytes: latest.size_bytes,
|
||||
device_id: latest.device_id,
|
||||
bytes: payload.into_bytes(),
|
||||
})
|
||||
user_id: &str,
|
||||
vault: &ResolvedVault,
|
||||
mut latest: SyncLatestSnapshotDocument,
|
||||
) -> Result<AuthenticatedRemote, SyncClientError> {
|
||||
for _ in 0..3 {
|
||||
let account_key = self.key_for_snapshot(client, user_id, vault, &latest)?;
|
||||
let expected_head = latest.head_ref()?;
|
||||
match client.download_snapshot(&expected_head)? {
|
||||
SnapshotDownloadResult::Downloaded(download) => {
|
||||
let (bytes, merge_base) =
|
||||
download.authenticate(&expected_head, &account_key)?.into_parts();
|
||||
return Ok(AuthenticatedRemote { bytes, merge_base });
|
||||
}
|
||||
SnapshotDownloadResult::Conflict(conflict) => {
|
||||
latest = conflict_head(conflict)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(SyncClientError::SnapshotBusy)
|
||||
}
|
||||
|
||||
fn key_for_snapshot(
|
||||
&self,
|
||||
client: &SyncApiClient,
|
||||
user_id: &str,
|
||||
vault: &ResolvedVault,
|
||||
snapshot: &SyncLatestSnapshotDocument,
|
||||
) -> Result<AccountKey, SyncClientError> {
|
||||
if !matches!(snapshot.encryption_version, 1 | SNAPSHOT_ENCRYPTION_VERSION) {
|
||||
return Err(SyncClientError::SnapshotEncryption {
|
||||
reason: "remote snapshot encryption version is unsupported",
|
||||
});
|
||||
}
|
||||
ensure_remote_generation_is_available(snapshot.vault_generation, vault.generation)?;
|
||||
if snapshot.vault_generation == vault.generation {
|
||||
if snapshot.key_id != vault.account_key.key_id() {
|
||||
return Err(SyncClientError::AccountKeyUnavailable);
|
||||
}
|
||||
return Ok(vault.account_key.clone());
|
||||
}
|
||||
self.resolve_historical_key(client, user_id, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedVault {
|
||||
account_key: AccountKey,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
struct AuthenticatedRemote {
|
||||
bytes: Vec<u8>,
|
||||
merge_base: AuthenticatedSnapshotHead,
|
||||
}
|
||||
|
||||
impl AuthenticatedRemote {
|
||||
fn into_outcome(self, cas_conflict: bool) -> SyncOutcome {
|
||||
SyncOutcome::RemoteSnapshot {
|
||||
snapshot_id: self.merge_base.snapshot_id().to_string(),
|
||||
logical_clock: self.merge_base.logical_clock(),
|
||||
payload_bytes: self.merge_base.size_bytes(),
|
||||
device_id: self.merge_base.device_id().to_string(),
|
||||
bytes: self.bytes,
|
||||
merge_base: self.merge_base,
|
||||
cas_conflict,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,6 +358,8 @@ pub enum SyncOutcome {
|
||||
payload_bytes: u64,
|
||||
device_id: String,
|
||||
bytes: Vec<u8>,
|
||||
merge_base: AuthenticatedSnapshotHead,
|
||||
cas_conflict: bool,
|
||||
},
|
||||
Uploaded {
|
||||
snapshot_id: String,
|
||||
@@ -224,6 +369,23 @@ pub enum SyncOutcome {
|
||||
},
|
||||
}
|
||||
|
||||
fn validate_sync_status(
|
||||
status: &ely_sync_client::SyncStatusDocument,
|
||||
user_id: &str,
|
||||
device_id: &str,
|
||||
) -> Result<(), SyncClientError> {
|
||||
if status.version != 2
|
||||
|| status.user_id != user_id
|
||||
|| status.device_id != device_id
|
||||
|| status.devices.current_device_id != device_id
|
||||
|| !status.devices.current_device_approved
|
||||
|| (status.snapshots.total_snapshots == 0) != status.snapshots.head.is_none()
|
||||
{
|
||||
return Err(SyncClientError::DeviceTrust { reason: "sync status identity is invalid" });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub struct SyncSnapshotApplySummary {
|
||||
imported: usize,
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
use ely_sync_client::{
|
||||
SyncClientError, SyncLatestSnapshotDocument, SyncSnapshotHeadConflictDocument,
|
||||
};
|
||||
|
||||
pub(super) fn conflict_head(
|
||||
conflict: SyncSnapshotHeadConflictDocument,
|
||||
) -> Result<SyncLatestSnapshotDocument, SyncClientError> {
|
||||
conflict.current_head.ok_or(SyncClientError::SnapshotBusy)
|
||||
}
|
||||
|
||||
pub(super) fn ensure_remote_generation_is_available(
|
||||
remote_generation: u64,
|
||||
resolved_generation: u64,
|
||||
) -> Result<(), SyncClientError> {
|
||||
if remote_generation > resolved_generation {
|
||||
return Err(SyncClientError::SnapshotBusy);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_conflict_head_requests_a_bounded_retry() {
|
||||
let conflict = SyncSnapshotHeadConflictDocument {
|
||||
version: 1,
|
||||
error: "sync_snapshot_head_conflict".to_string(),
|
||||
current_head: None,
|
||||
};
|
||||
assert!(matches!(conflict_head(conflict), Err(SyncClientError::SnapshotBusy)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_vault_generation_ahead_requests_a_bounded_retry() {
|
||||
assert!(matches!(
|
||||
ensure_remote_generation_is_available(2, 1),
|
||||
Err(SyncClientError::SnapshotBusy)
|
||||
));
|
||||
assert!(ensure_remote_generation_is_available(2, 2).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
use ely_sync_client::{
|
||||
AccountKey, DeviceApprovalDocument, DeviceApprovalRequest, DeviceListResponse, DeviceRecord,
|
||||
DeviceRevocationDocument, DeviceRevocationRequest, SyncApiClient, SyncClientError,
|
||||
VaultContext, WrappedAccountKey,
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::SyncEngine;
|
||||
|
||||
impl SyncEngine {
|
||||
pub fn cloud_devices(&self) -> Result<DeviceListResponse, SyncClientError> {
|
||||
let client = self.authenticated_client()?;
|
||||
let (client, registration) = self.registered_client(client)?;
|
||||
let devices = client.list_devices()?;
|
||||
validate_device_list(
|
||||
&devices,
|
||||
®istration.user_id,
|
||||
&self.identity.device_id,
|
||||
registration.device.is_approved(),
|
||||
)?;
|
||||
Ok(devices)
|
||||
}
|
||||
|
||||
pub fn approve_cloud_device(
|
||||
&self,
|
||||
target_device_id: &str,
|
||||
verification_code: &str,
|
||||
) -> Result<DeviceApprovalDocument, SyncClientError> {
|
||||
let (client, user_id) = self.approved_device_client()?;
|
||||
let devices = client.list_devices()?;
|
||||
validate_device_list(&devices, &user_id, &self.identity.device_id, true)?;
|
||||
let target = pending_device(&devices.devices, target_device_id)?;
|
||||
if !target.verification_code()?.eq_ignore_ascii_case(verification_code.trim()) {
|
||||
return Err(SyncClientError::DeviceTrust {
|
||||
reason: "device verification code does not match",
|
||||
});
|
||||
}
|
||||
let wrapping_public_key =
|
||||
target.wrapping_public_key.as_deref().ok_or(SyncClientError::DeviceTrust {
|
||||
reason: "pending device has no wrapping public key",
|
||||
})?;
|
||||
|
||||
let vault = self.resolve_vault(&client, &user_id)?;
|
||||
let key_id = vault.account_key.key_id();
|
||||
let envelope = WrappedAccountKey::wrap(
|
||||
&vault.account_key,
|
||||
&VaultContext {
|
||||
user_id: &user_id,
|
||||
recipient_device_id: &target.device_id,
|
||||
recipient_wrapping_public_key: wrapping_public_key,
|
||||
approver_device_id: &self.identity.device_id,
|
||||
generation: vault.generation,
|
||||
key_id: &key_id,
|
||||
},
|
||||
)?;
|
||||
let idempotency_key = format!("device-approval:{}", Uuid::now_v7().simple());
|
||||
let request = DeviceApprovalRequest::new(
|
||||
&user_id,
|
||||
&self.identity,
|
||||
&target.device_id,
|
||||
&key_id,
|
||||
vault.generation,
|
||||
&envelope,
|
||||
&idempotency_key,
|
||||
)?;
|
||||
let document = client.approve_device(&request)?;
|
||||
validate_approval(&document, &user_id, &self.identity.device_id, &target.device_id)?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
pub fn revoke_cloud_device(
|
||||
&self,
|
||||
target_device_id: &str,
|
||||
) -> Result<DeviceRevocationDocument, SyncClientError> {
|
||||
let (client, user_id) = self.approved_device_client()?;
|
||||
let devices = client.list_devices()?;
|
||||
validate_device_list(&devices, &user_id, &self.identity.device_id, true)?;
|
||||
let target = revocable_device(&devices.devices, target_device_id)?;
|
||||
let idempotency_key = format!("device-revocation:{}", Uuid::now_v7().simple());
|
||||
if target.approval_status == "pending" {
|
||||
let request = DeviceRevocationRequest::pending(
|
||||
&user_id,
|
||||
&self.identity,
|
||||
&target.device_id,
|
||||
&idempotency_key,
|
||||
)?;
|
||||
let document = client.revoke_device(&request)?;
|
||||
validate_pending_revocation(
|
||||
&document,
|
||||
&user_id,
|
||||
&self.identity.device_id,
|
||||
&target.device_id,
|
||||
)?;
|
||||
return Ok(document);
|
||||
}
|
||||
let vault = self.resolve_vault(&client, &user_id)?;
|
||||
let previous_key_id = vault.account_key.key_id();
|
||||
let new_generation =
|
||||
vault.generation.checked_add(1).ok_or(SyncClientError::DeviceTrust {
|
||||
reason: "device revocation generation overflowed",
|
||||
})?;
|
||||
let new_key = AccountKey::generate()?;
|
||||
let new_key_id = new_key.key_id();
|
||||
let envelopes = rotation_envelopes(
|
||||
&devices.devices,
|
||||
&target.device_id,
|
||||
&user_id,
|
||||
&self.identity.device_id,
|
||||
&new_key,
|
||||
new_generation,
|
||||
&new_key_id,
|
||||
)?;
|
||||
let request = DeviceRevocationRequest::approved_rotation(
|
||||
&user_id,
|
||||
&self.identity,
|
||||
&target.device_id,
|
||||
&previous_key_id,
|
||||
vault.generation,
|
||||
&new_key_id,
|
||||
new_generation,
|
||||
envelopes,
|
||||
&idempotency_key,
|
||||
)?;
|
||||
let document = client.revoke_device(&request)?;
|
||||
validate_approved_revocation(
|
||||
&document,
|
||||
&user_id,
|
||||
&self.identity.device_id,
|
||||
&target.device_id,
|
||||
&new_key_id,
|
||||
new_generation,
|
||||
)?;
|
||||
self.account_key_store(&user_id)?.save_current(&new_key, new_generation)?;
|
||||
Ok(document)
|
||||
}
|
||||
|
||||
fn approved_device_client(&self) -> Result<(SyncApiClient, String), SyncClientError> {
|
||||
let client = self.authenticated_client()?;
|
||||
self.approved_client(client)?.ok_or_else(|| SyncClientError::DeviceApprovalStatus {
|
||||
device_id: self.identity.device_id.clone(),
|
||||
status: "pending".to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn authenticated_client(&self) -> Result<SyncApiClient, SyncClientError> {
|
||||
let bearer = self.bearer_store.load()?.ok_or(SyncClientError::DeviceTrust {
|
||||
reason: "device management requires an authenticated session",
|
||||
})?;
|
||||
SyncApiClient::new(self.api_config.clone(), bearer)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_device_list(
|
||||
document: &DeviceListResponse,
|
||||
user_id: &str,
|
||||
current_device_id: &str,
|
||||
require_approved: bool,
|
||||
) -> Result<(), SyncClientError> {
|
||||
let current = document.devices.iter().filter(|device| device.current).collect::<Vec<_>>();
|
||||
if document.version != 1
|
||||
|| document.user_id != user_id
|
||||
|| current.len() != 1
|
||||
|| current[0].device_id != current_device_id
|
||||
|| (require_approved && !current[0].is_approved())
|
||||
{
|
||||
return Err(SyncClientError::DeviceTrust {
|
||||
reason: "device list does not match the authenticated device",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pending_device<'a>(
|
||||
devices: &'a [DeviceRecord],
|
||||
target_device_id: &str,
|
||||
) -> Result<&'a DeviceRecord, SyncClientError> {
|
||||
let target = devices
|
||||
.iter()
|
||||
.find(|device| device.device_id == target_device_id)
|
||||
.ok_or(SyncClientError::DeviceTrust { reason: "pending device was not found" })?;
|
||||
if target.current || target.approval_status != "pending" || target.revoked_at.is_some() {
|
||||
return Err(SyncClientError::DeviceTrust { reason: "device is not pending approval" });
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
fn revocable_device<'a>(
|
||||
devices: &'a [DeviceRecord],
|
||||
target_device_id: &str,
|
||||
) -> Result<&'a DeviceRecord, SyncClientError> {
|
||||
let target = devices
|
||||
.iter()
|
||||
.find(|device| device.device_id == target_device_id)
|
||||
.ok_or(SyncClientError::DeviceTrust { reason: "device was not found" })?;
|
||||
if target.current
|
||||
|| target.revoked_at.is_some()
|
||||
|| !matches!(target.approval_status.as_str(), "pending" | "approved")
|
||||
{
|
||||
return Err(SyncClientError::DeviceTrust { reason: "device cannot be revoked" });
|
||||
}
|
||||
Ok(target)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn rotation_envelopes(
|
||||
devices: &[DeviceRecord],
|
||||
target_device_id: &str,
|
||||
user_id: &str,
|
||||
approver_device_id: &str,
|
||||
new_key: &AccountKey,
|
||||
new_generation: u64,
|
||||
new_key_id: &str,
|
||||
) -> Result<Vec<(String, WrappedAccountKey)>, SyncClientError> {
|
||||
devices
|
||||
.iter()
|
||||
.filter(|device| device.device_id != target_device_id && device.is_approved())
|
||||
.filter_map(|device| {
|
||||
device
|
||||
.wrapping_public_key
|
||||
.as_deref()
|
||||
.map(|wrapping_public_key| (device, wrapping_public_key))
|
||||
})
|
||||
.map(|(device, wrapping_public_key)| {
|
||||
let envelope = WrappedAccountKey::wrap(
|
||||
new_key,
|
||||
&VaultContext {
|
||||
user_id,
|
||||
recipient_device_id: &device.device_id,
|
||||
recipient_wrapping_public_key: wrapping_public_key,
|
||||
approver_device_id,
|
||||
generation: new_generation,
|
||||
key_id: new_key_id,
|
||||
},
|
||||
)?;
|
||||
Ok((device.device_id.clone(), envelope))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn validate_approval(
|
||||
document: &DeviceApprovalDocument,
|
||||
user_id: &str,
|
||||
approver_device_id: &str,
|
||||
target_device_id: &str,
|
||||
) -> Result<(), SyncClientError> {
|
||||
if document.version != 1
|
||||
|| document.user_id != user_id
|
||||
|| document.approved_by_device_id != approver_device_id
|
||||
|| document.device.device_id != target_device_id
|
||||
|| !document.device.is_approved()
|
||||
|| document.device.approved_at != Some(document.approved_at)
|
||||
{
|
||||
return Err(SyncClientError::DeviceTrust {
|
||||
reason: "device approval response does not match the request",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_approved_revocation(
|
||||
document: &DeviceRevocationDocument,
|
||||
user_id: &str,
|
||||
approver_device_id: &str,
|
||||
target_device_id: &str,
|
||||
key_id: &str,
|
||||
generation: u64,
|
||||
) -> Result<(), SyncClientError> {
|
||||
let DeviceRevocationDocument::ApprovedRotate {
|
||||
version,
|
||||
user_id: response_user_id,
|
||||
revoked_by_device_id,
|
||||
revoked_at,
|
||||
key_id: response_key_id,
|
||||
generation: response_generation,
|
||||
device,
|
||||
} = document
|
||||
else {
|
||||
return Err(revocation_response_error());
|
||||
};
|
||||
if response_key_id != key_id || *response_generation != generation {
|
||||
return Err(revocation_response_error());
|
||||
}
|
||||
validate_revocation_common(
|
||||
*version,
|
||||
response_user_id,
|
||||
revoked_by_device_id,
|
||||
*revoked_at,
|
||||
device,
|
||||
user_id,
|
||||
approver_device_id,
|
||||
target_device_id,
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_pending_revocation(
|
||||
document: &DeviceRevocationDocument,
|
||||
user_id: &str,
|
||||
approver_device_id: &str,
|
||||
target_device_id: &str,
|
||||
) -> Result<(), SyncClientError> {
|
||||
let DeviceRevocationDocument::PendingRevoke {
|
||||
version,
|
||||
user_id: response_user_id,
|
||||
revoked_by_device_id,
|
||||
revoked_at,
|
||||
device,
|
||||
} = document
|
||||
else {
|
||||
return Err(revocation_response_error());
|
||||
};
|
||||
validate_revocation_common(
|
||||
*version,
|
||||
response_user_id,
|
||||
revoked_by_device_id,
|
||||
*revoked_at,
|
||||
device,
|
||||
user_id,
|
||||
approver_device_id,
|
||||
target_device_id,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn validate_revocation_common(
|
||||
version: u32,
|
||||
response_user_id: &str,
|
||||
revoked_by_device_id: &str,
|
||||
revoked_at: u64,
|
||||
device: &DeviceRecord,
|
||||
user_id: &str,
|
||||
approver_device_id: &str,
|
||||
target_device_id: &str,
|
||||
) -> Result<(), SyncClientError> {
|
||||
if version == 2
|
||||
&& response_user_id == user_id
|
||||
&& revoked_by_device_id == approver_device_id
|
||||
&& device.device_id == target_device_id
|
||||
&& device.approval_status == "revoked"
|
||||
&& device.revoked_at == Some(revoked_at)
|
||||
&& !device.current
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Err(revocation_response_error())
|
||||
}
|
||||
|
||||
fn revocation_response_error() -> SyncClientError {
|
||||
SyncClientError::DeviceTrust { reason: "device revocation response does not match the request" }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn device(device_id: &str, status: &str, current: bool) -> DeviceRecord {
|
||||
DeviceRecord {
|
||||
device_id: device_id.to_string(),
|
||||
public_key: "01".repeat(32),
|
||||
wrapping_public_key: Some("02".repeat(32)),
|
||||
device_name: "Test".to_string(),
|
||||
platform: "macos".to_string(),
|
||||
approval_status: status.to_string(),
|
||||
current,
|
||||
created_at: 1,
|
||||
approved_at: (status == "approved").then_some(2),
|
||||
last_active_at: None,
|
||||
revoked_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_list_requires_one_approved_current_device() {
|
||||
let document = DeviceListResponse {
|
||||
version: 1,
|
||||
user_id: "user-01".to_string(),
|
||||
devices: vec![device("device-01", "approved", true)],
|
||||
};
|
||||
assert!(validate_device_list(&document, "user-01", "device-01", true).is_ok());
|
||||
assert!(validate_device_list(&document, "user-02", "device-01", true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_target_must_be_pending() {
|
||||
let devices =
|
||||
[device("device-01", "approved", true), device("device-02", "pending", false)];
|
||||
assert!(matches!(
|
||||
pending_device(&devices, "device-02"),
|
||||
Ok(device) if device.device_id == "device-02"
|
||||
));
|
||||
assert!(pending_device(&devices, "device-01").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_response_requires_matching_device() {
|
||||
let device = device("device-02", "approved", false);
|
||||
let document = DeviceApprovalDocument {
|
||||
version: 1,
|
||||
user_id: "user-01".to_string(),
|
||||
approved_by_device_id: "device-01".to_string(),
|
||||
approved_at: 2,
|
||||
device,
|
||||
};
|
||||
assert!(validate_approval(&document, "user-01", "device-01", "device-02").is_ok());
|
||||
assert!(validate_approval(&document, "user-01", "device-01", "device-03").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revocation_target_must_be_another_active_device() {
|
||||
let devices = [
|
||||
device("device-01", "approved", true),
|
||||
device("device-02", "approved", false),
|
||||
device("device-03", "revoked", false),
|
||||
];
|
||||
assert!(matches!(
|
||||
revocable_device(&devices, "device-02"),
|
||||
Ok(device) if device.device_id == "device-02"
|
||||
));
|
||||
assert!(revocable_device(&devices, "device-01").is_err());
|
||||
assert!(revocable_device(&devices, "device-03").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revocation_response_binds_rotated_key_and_target() {
|
||||
let mut revoked = device("device-02", "revoked", false);
|
||||
revoked.approved_at = Some(2);
|
||||
revoked.revoked_at = Some(3);
|
||||
let document = DeviceRevocationDocument::ApprovedRotate {
|
||||
version: 2,
|
||||
user_id: "user-01".to_string(),
|
||||
revoked_by_device_id: "device-01".to_string(),
|
||||
revoked_at: 3,
|
||||
key_id: "03".repeat(32),
|
||||
generation: 2,
|
||||
device: revoked,
|
||||
};
|
||||
assert!(
|
||||
validate_approved_revocation(
|
||||
&document,
|
||||
"user-01",
|
||||
"device-01",
|
||||
"device-02",
|
||||
&"03".repeat(32),
|
||||
2,
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
validate_approved_revocation(
|
||||
&document,
|
||||
"user-01",
|
||||
"device-01",
|
||||
"device-03",
|
||||
&"03".repeat(32),
|
||||
2,
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
use ely_sync_client::{
|
||||
AccountKey, AccountKeyStore, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument,
|
||||
SyncVaultBootstrapRequest, SyncVaultDocument, WrappedAccountKey,
|
||||
};
|
||||
|
||||
use super::{ResolvedVault, SyncEngine};
|
||||
|
||||
impl SyncEngine {
|
||||
pub(super) fn resolve_vault(
|
||||
&self,
|
||||
client: &SyncApiClient,
|
||||
user_id: &str,
|
||||
) -> Result<ResolvedVault, SyncClientError> {
|
||||
match client.current_sync_vault() {
|
||||
Ok(document) => self.resolve_vault_document(user_id, document),
|
||||
Err(SyncClientError::HttpStatus { status: 404, .. }) => {
|
||||
self.bootstrap_vault(client, user_id)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_vault_document(
|
||||
&self,
|
||||
user_id: &str,
|
||||
document: SyncVaultDocument,
|
||||
) -> Result<ResolvedVault, SyncClientError> {
|
||||
let store = self.account_key_store(user_id)?;
|
||||
let key = document.unwrap_for(user_id, &self.identity)?;
|
||||
if let Some(stored) = store.load()? {
|
||||
if stored.current_generation() > document.generation {
|
||||
return Err(SyncClientError::VaultCrypto {
|
||||
reason: "sync vault generation rolled back",
|
||||
});
|
||||
}
|
||||
if let Some(stored_key) = stored.key(document.generation)
|
||||
&& stored_key.key_id() != key.key_id()
|
||||
{
|
||||
return Err(SyncClientError::VaultCrypto {
|
||||
reason: "sync vault key changed within one generation",
|
||||
});
|
||||
}
|
||||
}
|
||||
store.save_current(&key, document.generation)?;
|
||||
Ok(ResolvedVault { account_key: key, generation: document.generation })
|
||||
}
|
||||
|
||||
fn bootstrap_vault(
|
||||
&self,
|
||||
client: &SyncApiClient,
|
||||
user_id: &str,
|
||||
) -> Result<ResolvedVault, SyncClientError> {
|
||||
let store = self.account_key_store(user_id)?;
|
||||
if store.load()?.is_some() {
|
||||
return Err(SyncClientError::VaultCrypto {
|
||||
reason: "sync vault bootstrap would discard stored key history",
|
||||
});
|
||||
}
|
||||
let key = AccountKey::generate()?;
|
||||
let envelope = WrappedAccountKey::self_wrap(&key, user_id, &self.identity, 1)?;
|
||||
let key_id = key.key_id();
|
||||
let idempotency_key = format!("vault-bootstrap:{}:{key_id}", self.identity.device_id);
|
||||
let request = SyncVaultBootstrapRequest::signed(
|
||||
user_id,
|
||||
&self.identity,
|
||||
&key_id,
|
||||
&envelope,
|
||||
&idempotency_key,
|
||||
)?;
|
||||
let document = client.bootstrap_sync_vault(&request)?;
|
||||
let confirmed_key = document.unwrap_for(user_id, &self.identity)?;
|
||||
if confirmed_key.key_id() != key_id {
|
||||
return Err(SyncClientError::AccountKeyUnavailable);
|
||||
}
|
||||
store.save_current(&confirmed_key, document.generation)?;
|
||||
Ok(ResolvedVault { account_key: confirmed_key, generation: document.generation })
|
||||
}
|
||||
|
||||
pub(super) fn resolve_historical_key(
|
||||
&self,
|
||||
client: &SyncApiClient,
|
||||
user_id: &str,
|
||||
snapshot: &SyncLatestSnapshotDocument,
|
||||
) -> Result<AccountKey, SyncClientError> {
|
||||
let store = self.account_key_store(user_id)?;
|
||||
if let Some(stored) = store.load()?
|
||||
&& let Some(key) = stored.key(snapshot.vault_generation)
|
||||
{
|
||||
if key.key_id() != snapshot.key_id {
|
||||
return Err(SyncClientError::AccountKeyUnavailable);
|
||||
}
|
||||
return Ok(key.clone());
|
||||
}
|
||||
let document = client.sync_vault_generation(snapshot.vault_generation, &snapshot.key_id)?;
|
||||
let key = document.unwrap_for(user_id, &self.identity)?;
|
||||
if document.generation != snapshot.vault_generation || key.key_id() != snapshot.key_id {
|
||||
return Err(SyncClientError::AccountKeyUnavailable);
|
||||
}
|
||||
store.save_historical(&key, snapshot.vault_generation)?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub(super) fn account_key_store(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<AccountKeyStore, SyncClientError> {
|
||||
AccountKeyStore::new(user_id, &self.account_key_lock_dir)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user