feat: e2e encrypted sync

This commit is contained in:
zhom
2026-02-24 05:51:48 +04:00
parent 21d80fde56
commit e6cb4e6082
56 changed files with 5831 additions and 2549 deletions
+294 -30
View File
@@ -1,8 +1,9 @@
use super::client::SyncClient;
use super::encryption;
use super::manifest::{compute_diff, generate_manifest, get_cache_path, HashCache, SyncManifest};
use super::types::*;
use crate::events;
use crate::profile::types::BrowserProfile;
use crate::profile::types::{BrowserProfile, SyncMode};
use crate::profile::ProfileManager;
use crate::settings_manager::SettingsManager;
use chrono::{DateTime, Utc};
@@ -12,6 +13,18 @@ use std::path::Path;
use std::sync::Arc;
use tokio::sync::Semaphore;
/// Check if sync is configured (cloud or self-hosted)
pub fn is_sync_configured() -> bool {
if crate::cloud_auth::CLOUD_AUTH.has_active_paid_subscription_sync() {
return true;
}
let manager = SettingsManager::instance();
if let Ok(settings) = manager.load_settings() {
return settings.sync_server_url.is_some();
}
false
}
pub struct SyncEngine {
client: SyncClient,
}
@@ -68,6 +81,24 @@ impl SyncEngine {
return Ok(());
}
// Derive encryption key if encrypted sync
let encryption_key = if profile.is_encrypted_sync() {
let password = encryption::load_e2e_password()
.map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))?
.ok_or_else(|| {
let _ = events::emit("profile-sync-e2e-password-required", ());
SyncError::InvalidData("E2E password not set".to_string())
})?;
let salt = profile.encryption_salt.as_deref().ok_or_else(|| {
SyncError::InvalidData("Encryption salt missing on encrypted profile".to_string())
})?;
let key = encryption::derive_profile_key(&password, salt)
.map_err(|e| SyncError::InvalidData(format!("Key derivation failed: {e}")))?;
Some(key)
} else {
None
};
let profile_manager = ProfileManager::instance();
let profiles_dir = profile_manager.get_profiles_dir();
let profile_dir = profiles_dir.join(profile.id.to_string());
@@ -154,7 +185,13 @@ impl SyncEngine {
// Perform uploads
if !diff.files_to_upload.is_empty() {
self
.upload_profile_files(app_handle, &profile_id, &profile_dir, &diff.files_to_upload)
.upload_profile_files(
app_handle,
&profile_id,
&profile_dir,
&diff.files_to_upload,
encryption_key.as_ref(),
)
.await?;
}
@@ -166,6 +203,7 @@ impl SyncEngine {
&profile_id,
&profile_dir,
&diff.files_to_download,
encryption_key.as_ref(),
)
.await?;
}
@@ -190,7 +228,9 @@ impl SyncEngine {
self.upload_profile_metadata(&profile_id, profile).await?;
// Upload manifest.json last for atomicity
self.upload_manifest(&profile_id, &local_manifest).await?;
let mut final_manifest = local_manifest;
final_manifest.encrypted = encryption_key.is_some();
self.upload_manifest(&profile_id, &final_manifest).await?;
// Sync associated proxy, group, and VPN
if let Some(proxy_id) = &profile.proxy_id {
@@ -291,6 +331,7 @@ impl SyncEngine {
profile_id: &str,
profile_dir: &Path,
files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>,
) -> SyncResult<()> {
if files.is_empty() {
return Ok(());
@@ -324,6 +365,7 @@ impl SyncEngine {
let client = self.client.clone();
let profile_dir = profile_dir.to_path_buf();
let profile_id = profile_id.to_string();
let enc_key = encryption_key.copied();
let mut handles = Vec::new();
@@ -355,8 +397,20 @@ impl SyncEngine {
}
};
let upload_data = if let Some(ref key) = enc_key {
match encryption::encrypt_bytes(key, &data) {
Ok(encrypted) => encrypted,
Err(e) => {
log::warn!("Failed to encrypt {}: {}", file_path.display(), e);
return;
}
}
} else {
data
};
if let Err(e) = client
.upload_bytes(&url, &data, content_type.as_deref())
.upload_bytes(&url, &upload_data, content_type.as_deref())
.await
{
log::warn!("Failed to upload {}: {}", file_path.display(), e);
@@ -387,6 +441,7 @@ impl SyncEngine {
profile_id: &str,
profile_dir: &Path,
files: &[super::manifest::ManifestFileEntry],
encryption_key: Option<&[u8; 32]>,
) -> SyncResult<()> {
if files.is_empty() {
return Ok(());
@@ -418,6 +473,7 @@ impl SyncEngine {
let client = self.client.clone();
let profile_dir = profile_dir.to_path_buf();
let profile_id = profile_id.to_string();
let enc_key = encryption_key.copied();
let mut handles = Vec::new();
@@ -440,10 +496,22 @@ impl SyncEngine {
match client.download_bytes(&url).await {
Ok(data) => {
let write_data = if let Some(ref key) = enc_key {
match encryption::decrypt_bytes(key, &data) {
Ok(decrypted) => decrypted,
Err(e) => {
log::warn!("Failed to decrypt {}, skipping: {}", remote_key, e);
return;
}
}
} else {
data
};
if let Some(parent) = file_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Err(e) = fs::write(&file_path, &data) {
if let Err(e) = fs::write(&file_path, &write_data) {
log::warn!("Failed to write {}: {}", file_path.display(), e);
}
}
@@ -1016,7 +1084,9 @@ impl SyncEngine {
))
})?;
profile.sync_enabled = true;
if profile.sync_mode == SyncMode::Disabled {
profile.sync_mode = SyncMode::Regular;
}
profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1052,6 +1122,26 @@ impl SyncEngine {
));
};
// If remote manifest is encrypted, we need the E2E password
let encryption_key = if manifest.encrypted {
let password = encryption::load_e2e_password()
.map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))?
.ok_or_else(|| {
let _ = events::emit("profile-sync-e2e-password-required", ());
SyncError::InvalidData(
"Remote profile is encrypted but no E2E password is set".to_string(),
)
})?;
let salt = profile.encryption_salt.as_deref().ok_or_else(|| {
SyncError::InvalidData("Encryption salt missing on encrypted profile".to_string())
})?;
let key = encryption::derive_profile_key(&password, salt)
.map_err(|e| SyncError::InvalidData(format!("Key derivation failed: {e}")))?;
Some(key)
} else {
None
};
// Ensure profile directory exists
fs::create_dir_all(&profile_dir).map_err(|e| {
SyncError::IoError(format!(
@@ -1078,12 +1168,24 @@ impl SyncEngine {
}
if !manifest.files.is_empty() {
self
.download_profile_files(app_handle, profile_id, &profile_dir, &manifest.files)
.download_profile_files(
app_handle,
profile_id,
&profile_dir,
&manifest.files,
encryption_key.as_ref(),
)
.await?;
}
// Set sync enabled and save profile
profile.sync_enabled = true;
// Set sync mode and save profile
if profile.sync_mode == SyncMode::Disabled {
profile.sync_mode = if manifest.encrypted {
SyncMode::Encrypted
} else {
SyncMode::Regular
};
}
profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1170,23 +1272,23 @@ impl SyncEngine {
// Refresh metadata for local cross-OS profiles (propagate renames, tags, notes from originating device)
let profile_manager = ProfileManager::instance();
// Collect cross-OS profiles before async operations to avoid holding non-Send Result across await
let cross_os_profiles: Vec<(String, bool)> = profile_manager
let cross_os_profiles: Vec<(String, SyncMode)> = profile_manager
.list_profiles()
.unwrap_or_default()
.iter()
.filter(|p| p.is_cross_os() && p.sync_enabled)
.map(|p| (p.id.to_string(), p.sync_enabled))
.filter(|p| p.is_cross_os() && p.is_sync_enabled())
.map(|p| (p.id.to_string(), p.sync_mode))
.collect();
if !cross_os_profiles.is_empty() {
for (pid, sync_enabled) in &cross_os_profiles {
for (pid, sync_mode) in &cross_os_profiles {
let metadata_key = format!("profiles/{}/metadata.json", pid);
match self.client.stat(&metadata_key).await {
Ok(stat) if stat.exists => match self.client.presign_download(&metadata_key).await {
Ok(presign) => match self.client.download_bytes(&presign.url).await {
Ok(data) => {
if let Ok(mut remote_profile) = serde_json::from_slice::<BrowserProfile>(&data) {
remote_profile.sync_enabled = *sync_enabled;
remote_profile.sync_mode = *sync_mode;
remote_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -1220,6 +1322,111 @@ impl SyncEngine {
Ok(downloaded)
}
/// Check for remote entities (proxies, groups, VPNs) not present locally and download them
pub async fn check_for_missing_synced_entities(
&self,
app_handle: &tauri::AppHandle,
) -> SyncResult<()> {
log::info!("Checking for missing synced entities...");
// Check for remote proxies not present locally
let remote_proxies = self.client.list("proxies/").await?;
for obj in &remote_proxies.objects {
if let Some(proxy_id) = obj
.key
.strip_prefix("proxies/")
.and_then(|s| s.strip_suffix(".json"))
{
let exists_locally = crate::proxy_manager::PROXY_MANAGER
.get_stored_proxies()
.iter()
.any(|p| p.id == proxy_id);
if !exists_locally {
let tombstone_key = format!("tombstones/proxies/{}.json", proxy_id);
if let Ok(stat) = self.client.stat(&tombstone_key).await {
if stat.exists {
continue;
}
}
log::info!(
"Proxy {} exists remotely but not locally, downloading...",
proxy_id
);
if let Err(e) = self.download_proxy(proxy_id, Some(app_handle)).await {
log::warn!("Failed to download missing proxy {}: {}", proxy_id, e);
}
}
}
}
// Check for remote groups not present locally
let remote_groups = self.client.list("groups/").await?;
for obj in &remote_groups.objects {
if let Some(group_id) = obj
.key
.strip_prefix("groups/")
.and_then(|s| s.strip_suffix(".json"))
{
let exists_locally = {
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
group_manager
.get_all_groups()
.unwrap_or_default()
.iter()
.any(|g| g.id == group_id)
};
if !exists_locally {
let tombstone_key = format!("tombstones/groups/{}.json", group_id);
if let Ok(stat) = self.client.stat(&tombstone_key).await {
if stat.exists {
continue;
}
}
log::info!(
"Group {} exists remotely but not locally, downloading...",
group_id
);
if let Err(e) = self.download_group(group_id, Some(app_handle)).await {
log::warn!("Failed to download missing group {}: {}", group_id, e);
}
}
}
}
// Check for remote VPNs not present locally
let remote_vpns = self.client.list("vpns/").await?;
for obj in &remote_vpns.objects {
if let Some(vpn_id) = obj
.key
.strip_prefix("vpns/")
.and_then(|s| s.strip_suffix(".json"))
{
let exists_locally = {
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage.load_config(vpn_id).is_ok()
};
if !exists_locally {
let tombstone_key = format!("tombstones/vpns/{}.json", vpn_id);
if let Ok(stat) = self.client.stat(&tombstone_key).await {
if stat.exists {
continue;
}
}
log::info!(
"VPN {} exists remotely but not locally, downloading...",
vpn_id
);
if let Err(e) = self.download_vpn(vpn_id, Some(app_handle)).await {
log::warn!("Failed to download missing VPN {}: {}", vpn_id, e);
}
}
}
}
log::info!("Missing synced entities check complete");
Ok(())
}
}
/// Check if proxy is used by any synced profile
@@ -1228,7 +1435,7 @@ pub fn is_proxy_used_by_synced_profile(proxy_id: &str) -> bool {
if let Ok(profiles) = profile_manager.list_profiles() {
profiles
.iter()
.any(|p| p.sync_enabled && p.proxy_id.as_deref() == Some(proxy_id))
.any(|p| p.is_sync_enabled() && p.proxy_id.as_deref() == Some(proxy_id))
} else {
false
}
@@ -1240,7 +1447,7 @@ pub fn is_group_used_by_synced_profile(group_id: &str) -> bool {
if let Ok(profiles) = profile_manager.list_profiles() {
profiles
.iter()
.any(|p| p.sync_enabled && p.group_id.as_deref() == Some(group_id))
.any(|p| p.is_sync_enabled() && p.group_id.as_deref() == Some(group_id))
} else {
false
}
@@ -1281,7 +1488,7 @@ pub fn is_vpn_used_by_synced_profile(vpn_id: &str) -> bool {
if let Ok(profiles) = profile_manager.list_profiles() {
profiles
.iter()
.any(|p| p.sync_enabled && p.vpn_id.as_deref() == Some(vpn_id))
.any(|p| p.is_sync_enabled() && p.vpn_id.as_deref() == Some(vpn_id))
} else {
false
}
@@ -1346,11 +1553,18 @@ pub async fn enable_group_sync_if_needed(
}
#[tauri::command]
pub async fn set_profile_sync_enabled(
pub async fn set_profile_sync_mode(
app_handle: tauri::AppHandle,
profile_id: String,
enabled: bool,
sync_mode: String,
) -> Result<(), String> {
let new_mode = match sync_mode.as_str() {
"Disabled" => SyncMode::Disabled,
"Regular" => SyncMode::Regular,
"Encrypted" => SyncMode::Encrypted,
_ => return Err(format!("Invalid sync mode: {sync_mode}")),
};
let profile_manager = ProfileManager::instance();
let profiles = profile_manager
.list_profiles()
@@ -1367,9 +1581,14 @@ pub async fn set_profile_sync_enabled(
return Err("Cannot modify sync settings for a cross-OS profile".to_string());
}
// If enabling, first check that sync settings are configured
if enabled {
// Cloud auth provides sync settings dynamically — skip local checks
if profile.ephemeral {
return Err("Cannot enable sync for an ephemeral profile".to_string());
}
let old_mode = profile.sync_mode;
let enabling = new_mode != SyncMode::Disabled;
if enabling {
let cloud_logged_in = crate::cloud_auth::CLOUD_AUTH.is_logged_in().await;
if !cloud_logged_in {
@@ -1407,7 +1626,32 @@ pub async fn set_profile_sync_enabled(
}
}
profile.sync_enabled = enabled;
// If switching to Encrypted, verify password and generate salt
if new_mode == SyncMode::Encrypted {
if !encryption::has_e2e_password() {
return Err("E2E password not set. Please set a password in Settings first.".to_string());
}
if profile.encryption_salt.is_none() {
profile.encryption_salt = Some(encryption::generate_salt());
}
}
// If switching between Regular<->Encrypted, delete remote manifest to force full re-upload
let mode_switched = old_mode != SyncMode::Disabled && enabling && old_mode != new_mode;
if mode_switched {
if let Ok(engine) = SyncEngine::create_from_settings(&app_handle).await {
let manifest_key = format!("profiles/{}/manifest.json", profile_id);
let _ = engine.client.delete(&manifest_key, None).await;
log::info!(
"Deleted remote manifest for profile {} due to sync mode change ({:?} -> {:?})",
profile_id,
old_mode,
new_mode
);
}
}
profile.sync_mode = new_mode;
profile_manager
.save_profile(&profile)
@@ -1415,8 +1659,7 @@ pub async fn set_profile_sync_enabled(
let _ = events::emit("profiles-changed", ());
if enabled {
// Check if profile is running to determine status
if enabling {
let is_running = profile.process_id.is_some();
let _ = events::emit(
@@ -1427,13 +1670,11 @@ pub async fn set_profile_sync_enabled(
}),
);
// Queue sync via scheduler (not direct sync)
if let Some(scheduler) = super::get_global_scheduler() {
scheduler
.queue_profile_sync_immediate(profile_id.clone())
.await;
// Auto-enable sync for proxy and group if they exist
if let Some(ref proxy_id) = profile.proxy_id {
if let Err(e) = enable_proxy_sync_if_needed(proxy_id, &app_handle).await {
log::warn!("Failed to enable sync for proxy {}: {}", proxy_id, e);
@@ -1459,6 +1700,30 @@ pub async fn set_profile_sync_enabled(
log::warn!("Scheduler not initialized, sync will not start");
}
} else {
// Delete remote data when disabling sync
if old_mode != SyncMode::Disabled {
let profile_id_clone = profile_id.clone();
let app_handle_clone = app_handle.clone();
tokio::spawn(async move {
match SyncEngine::create_from_settings(&app_handle_clone).await {
Ok(engine) => {
if let Err(e) = engine.delete_profile(&profile_id_clone).await {
log::warn!(
"Failed to delete profile {} from sync: {}",
profile_id_clone,
e
);
} else {
log::info!("Profile {} deleted from sync service", profile_id_clone);
}
}
Err(e) => {
log::debug!("Sync not configured, skipping remote deletion: {}", e);
}
}
});
}
let _ = events::emit(
"profile-sync-status",
serde_json::json!({
@@ -1468,11 +1733,10 @@ pub async fn set_profile_sync_enabled(
);
}
// Report updated sync-enabled profile count to the cloud backend
if crate::cloud_auth::CLOUD_AUTH.is_logged_in().await {
let sync_count = profile_manager
.list_profiles()
.map(|profiles| profiles.iter().filter(|p| p.sync_enabled).count())
.map(|profiles| profiles.iter().filter(|p| p.is_sync_enabled()).count())
.unwrap_or(0);
tokio::spawn(async move {
@@ -1506,7 +1770,7 @@ pub async fn request_profile_sync(
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
if !profile.sync_enabled {
if !profile.is_sync_enabled() {
return Err("Sync is not enabled for this profile".to_string());
}