fix(sync): secure encrypted snapshot lifecycle

This commit is contained in:
2026-07-10 06:24:53 -04:00
parent 556c5ff624
commit 540b901fd6
106 changed files with 18026 additions and 3309 deletions
+1
View File
@@ -12,6 +12,7 @@ serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
url.workspace = true
uuid.workspace = true
[lints]
workspace = true
+217 -55
View File
@@ -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,
&registration.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)
}
}