diff --git a/src-tauri/src/cookie_manager.rs b/src-tauri/src/cookie_manager.rs index 5c5b0fc3..a534e997 100644 --- a/src-tauri/src/cookie_manager.rs +++ b/src-tauri/src/cookie_manager.rs @@ -1268,6 +1268,8 @@ mod tests { const SYNTHETIC_ENCRYPTED_COOKIE_HEX: &str = "763130d83b9fd3e6d1b1c793769f55251f5e9d1193be72c0c08ea32e2cf068a85d9d0b97d8b2e6deca93a2b3c290e98e1a851f83d5566f9aa9314befe56dc6bdbd423d"; /// `v10`, raw key file: AES-256-GCM, nonce 0xa0..0xab, then ciphertext and tag. const V10_RAW_KEY_GCM_HEX: &str = "763130a0a1a2a3a4a5a6a7a8a9aaab80ca93cabb34c5dd58110a3d46900c1fa9ecd0d2de71d7b20447d42094e312b029ee1c37a83550c88b77cbf131874eaccf3a660a9e9c574aba285fd5136dc8dfcc4f4cf4f000"; + /// `v10`, raw key file: AES-128-CBC, PBKDF2(the 32 bytes, "saltysalt", 1003). + const V10_RAW_KEY_CBC_HEX: &str = "7631301293fcc3514b2f9f809661394660f53164bc85085480268bb96eeff8ec82f45cbf38e0cc19bc44626a36381cd719fc7714a818351bfe922cd2283c9cb5e6a267"; /// `v11`, password file: AES-128-CBC, PBKDF2(password, "saltysalt", 1). const V11_PASSWORD_HEX: &str = "76313134ac798d9e3721442b1ff0eeaf137cbaa12cbf87938b967e5f63791f03bd1928007773702a89a8f4a53145d1ea1814815f2ab922cd4e1dbe341cd327af6cd02f"; /// `v11`, raw key file: AES-128-CBC, PBKDF2(the 32 bytes, "saltysalt", 1). @@ -1952,13 +1954,25 @@ mod tests { assert_eq!(open(V10_RAW_KEY_GCM_HEX, &password_key()), None); } + #[test] + fn a_raw_key_file_opens_a_cbc_v10_record_with_1003_iterations() { + assert_eq!( + open(V10_RAW_KEY_CBC_HEX, &raw_key()).as_deref(), + Some(SYNTHETIC_COOKIE_VALUE) + ); + assert_eq!( + open(V10_PEANUTS_FALLBACK_HEX, &raw_key()).as_deref(), + Some(SYNTHETIC_COOKIE_VALUE) + ); + } + #[test] fn a_password_key_file_opens_v10_with_1003_iterations() { assert_eq!( open(SYNTHETIC_ENCRYPTED_COOKIE_HEX, &password_key()).as_deref(), Some(SYNTHETIC_COOKIE_VALUE) ); - // A raw key file reads v10 as GCM, so this CBC record does not open there. + // A record keyed by another file's password does not open under a raw key. assert_eq!(open(SYNTHETIC_ENCRYPTED_COOKIE_HEX, &raw_key()), None); } diff --git a/src-tauri/src/profile/encryption.rs b/src-tauri/src/profile/encryption.rs index 0b23faac..e669731f 100644 --- a/src-tauri/src/profile/encryption.rs +++ b/src-tauri/src/profile/encryption.rs @@ -36,7 +36,8 @@ const TMP_EXTENSION: &str = "donut-tmp"; /// What a sealed copy leaves out: caches the browser rebuilds, lock and temp /// files, crash and metrics dumps, and the files Donut writes again before -/// every launch. Everything else is kept, the SQLite journals and write-ahead +/// every launch. Service worker scripts and Cache Storage are site data and +/// are sealed: without the script, Chromium drops the registration. Everything else is kept, the SQLite journals and write-ahead /// logs included: without them a database the browser did not close cleanly /// opens torn instead of rolled back. pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[ @@ -49,8 +50,6 @@ pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[ "**/DawnGraphiteCache/**", "**/DawnWebGPUCache/**", "**/GraphiteDawnCache/**", - "**/Service Worker/CacheStorage/**", - "**/Service Worker/ScriptCache/**", "**/blob_storage/**", "**/Crashpad/**", "**/Crash Reports/**", diff --git a/src-tauri/src/profile/encryption_tests.rs b/src-tauri/src/profile/encryption_tests.rs index a48478d1..29a074ec 100644 --- a/src-tauri/src/profile/encryption_tests.rs +++ b/src-tauri/src/profile/encryption_tests.rs @@ -338,6 +338,8 @@ fn the_sealed_exclusions_keep_what_a_database_needs() { "Default/History-wal", "Default/Secure Preferences", "Default/Session Storage/000003.log", + "Default/Service Worker/ScriptCache/index", + "Default/Service Worker/CacheStorage/index.txt", "Local State", "os_crypt_key", ] { diff --git a/src-tauri/src/profile/portable.rs b/src-tauri/src/profile/portable.rs index 762f4328..d1a93d98 100644 --- a/src-tauri/src/profile/portable.rs +++ b/src-tauri/src/profile/portable.rs @@ -141,7 +141,9 @@ fn collect_files(dir: &Path) -> Result, String> { /// Whether a path inside the profile directory is left out of an export. pub fn is_excluded(relative: &str) -> bool { - const CACHE_SEGMENTS: [&str; 10] = [ + // `Service Worker/CacheStorage` and `Service Worker/ScriptCache` are site + // data: without the script, Chromium drops the service worker registration. + const CACHE_SEGMENTS: [&str; 8] = [ "Cache", "Code Cache", "GPUCache", @@ -150,8 +152,6 @@ pub fn is_excluded(relative: &str) -> bool { "DawnCache", "DawnGraphiteCache", "GraphiteDawnCache", - "CacheStorage", - "ScriptCache", ]; const LAUNCH_FILES: [&str; 4] = [ "wayfern-identity.json", diff --git a/src-tauri/src/profile/trash.rs b/src-tauri/src/profile/trash.rs index e1f6ce9a..4533bc6c 100644 --- a/src-tauri/src/profile/trash.rs +++ b/src-tauri/src/profile/trash.rs @@ -44,15 +44,14 @@ const RESTORED_SUFFIX: &str = "(restored)"; /// Chromium directories that only ever hold caches, relative to the profile /// data directory. Every one of them is recreated by the browser on demand. -const CACHE_DIRS: [&str; 8] = [ +/// Service worker scripts and Cache Storage are site data and stay. +const CACHE_DIRS: [&str; 6] = [ "Cache", "Code Cache", "GPUCache", "GrShaderCache", "ShaderCache", "DawnCache", - "Service Worker/CacheStorage", - "Service Worker/ScriptCache", ]; /// Serialises every trash mutation so a restore cannot interleave with a diff --git a/src-tauri/src/profile_import/os_crypt.rs b/src-tauri/src/profile_import/os_crypt.rs index 641f371e..3794301b 100644 --- a/src-tauri/src/profile_import/os_crypt.rs +++ b/src-tauri/src/profile_import/os_crypt.rs @@ -137,7 +137,9 @@ impl CryptoKey { /// This key, then, for AES-128-CBC, the fixed keys Chromium retries a CBC /// record with when it does not open: the empty password /// ([`EMPTY_PASSWORD_KEY`]) and Linux's fixed password - /// ([`POSIX_FALLBACK_KEY`]). An AES-256-GCM key has no fallbacks. + /// ([`POSIX_FALLBACK_KEY`]). A raw key file's `v10` records can also be + /// AES-128-CBC, keyed by PBKDF2(the 32 bytes, "saltysalt", 1003) or by + /// Linux's fixed password, so an AES-256-GCM key falls back to those two. pub fn with_fallbacks(&self) -> Vec { match self { Self::Aes128Cbc(_) => vec![ @@ -145,7 +147,11 @@ impl CryptoKey { Self::Aes128Cbc(EMPTY_PASSWORD_KEY), Self::Aes128Cbc(POSIX_FALLBACK_KEY), ], - Self::Aes256Gcm(_) => vec![self.clone()], + Self::Aes256Gcm(raw) => vec![ + self.clone(), + Self::Aes128Cbc(derive_key(raw, MAC_ITERATIONS)), + Self::Aes128Cbc(POSIX_FALLBACK_KEY), + ], } } diff --git a/src-tauri/src/profile_import/os_crypt_tests.rs b/src-tauri/src/profile_import/os_crypt_tests.rs index 317784a8..66454050 100644 --- a/src-tauri/src/profile_import/os_crypt_tests.rs +++ b/src-tauri/src/profile_import/os_crypt_tests.rs @@ -59,7 +59,7 @@ fn the_key_file_format_and_the_tag_choose_the_key_on_every_host() { } #[test] -fn only_cbc_keys_carry_chromiums_fallbacks() { +fn every_key_carries_chromiums_fallbacks() { let cbc = CryptoKey::Aes128Cbc([1u8; 16]); let keys: Vec> = cbc.with_fallbacks().iter().map(key_bytes).collect(); assert_eq!( @@ -70,7 +70,22 @@ fn only_cbc_keys_carry_chromiums_fallbacks() { POSIX_FALLBACK_KEY.to_vec() ] ); - assert_eq!(CryptoKey::Aes256Gcm([1u8; 32]).with_fallbacks().len(), 1); + // A raw key file's v10 records can be AES-128-CBC under PBKDF2 of the raw + // bytes, or under Linux's fixed password. + let raw = [1u8; 32]; + let keys: Vec> = CryptoKey::Aes256Gcm(raw) + .with_fallbacks() + .iter() + .map(key_bytes) + .collect(); + assert_eq!( + keys, + vec![ + raw.to_vec(), + derive_key(&raw, MAC_ITERATIONS).to_vec(), + POSIX_FALLBACK_KEY.to_vec() + ] + ); } #[test] diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index fea27be8..cd19e9c1 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -12,6 +12,13 @@ use super::types::{SyncError, SyncResult}; /// Default exclude patterns for volatile browser profile files. /// Patterns use `**/` prefix to match at any directory depth, since the sync /// engine scans from `profiles/{uuid}/` which contains `profile/Default/...`. +/// +/// `Service Worker/ScriptCache` and `Service Worker/CacheStorage` are site +/// data, not caches: Chromium drops a service worker registration whose script +/// is missing, which loses the site's push subscription and offline data, and +/// an extension's background worker fails its first start. `Secure +/// Preferences` holds the extension, homepage and startup settings of profiles +/// written by browsers that kept tracked preferences there. pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[ "**/Cache/**", "**/Code Cache/**", @@ -20,8 +27,6 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[ "**/ShaderCache/**", "**/DawnCache/**", "**/DawnGraphiteCache/**", - "**/Service Worker/CacheStorage/**", - "**/Service Worker/ScriptCache/**", "**/Session Storage/**", "**/blob_storage/**", "**/Crashpad/**", @@ -61,9 +66,9 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[ "**/wayfern-persona.json", "**/SingletonSocket", "**/SingletonCookie", - "**/Secure Preferences", "**/GraphiteDawnCache/**", "**/DawnWebGPUCache/**", + "**/GPUPersistentCache/**", "**/BrowserMetrics*", "**/.DS_Store", // Profile metadata is a separately reconciled LWW config object. Including @@ -680,6 +685,36 @@ mod tests { ); } + #[test] + fn test_generate_manifest_keeps_site_data_and_secure_preferences() { + let temp_dir = TempDir::new().unwrap(); + let profile_dir = temp_dir.path().join("profile_root"); + let default_dir = profile_dir.join("profile/Default"); + for relative in [ + "Service Worker/Database/000001.log", + "Service Worker/ScriptCache/index", + "Service Worker/CacheStorage/index.txt", + "Secure Preferences", + ] { + let path = default_dir.join(relative); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "keep").unwrap(); + } + + let mut cache = HashCache::default(); + let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap(); + + let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect(); + for kept in [ + "profile/Default/Service Worker/Database/000001.log", + "profile/Default/Service Worker/ScriptCache/index", + "profile/Default/Service Worker/CacheStorage/index.txt", + "profile/Default/Secure Preferences", + ] { + assert!(paths.contains(&kept), "{kept} must sync: {paths:?}"); + } + } + #[test] fn test_generate_manifest_excludes_sqlite_shm_sidecars() { let temp_dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/wayfern_identity_storage.rs b/src-tauri/src/wayfern_identity_storage.rs index 7250e5b2..e2901f8c 100644 --- a/src-tauri/src/wayfern_identity_storage.rs +++ b/src-tauri/src/wayfern_identity_storage.rs @@ -156,6 +156,7 @@ pub fn converted_profile( let config = updated .wayfern_config .get_or_insert_with(WayfernConfig::default); + keep_persona(&profile.id, config, identity_id); config.identity_id = Some(identity_id.to_string()); config.os = Some(os.to_string()); config.identity_overrides = if overrides.is_empty() { @@ -176,6 +177,18 @@ pub fn converted_profile( Ok(updated) } +/// Keeps the person a profile presents when its identity changes: the +/// persona is derived from the identity id, or from the profile id when there +/// is none. +fn keep_persona(profile_id: &uuid::Uuid, config: &mut WayfernConfig, identity_id: &str) { + let seed = stored_identity_id(config) + .map(str::to_string) + .unwrap_or_else(|| profile_id.to_string()); + if seed != identity_id.trim() { + config.persona = crate::wayfern_persona::pinned_edits(&seed, config.persona.as_deref()); + } +} + pub(crate) fn read_profile(id: &uuid::Uuid) -> Option { let path = ProfileManager::instance() .get_profiles_dir() @@ -321,6 +334,7 @@ fn profile_with_result(profile: &BrowserProfile, result: &LaunchResult) -> Optio let config = updated .wayfern_config .get_or_insert_with(WayfernConfig::default); + keep_persona(&profile.id, config, &identity_id); config.identity_id = Some(identity_id); config.os = Some(os.to_string()); config.identity_overrides = text("identity_overrides"); @@ -1225,7 +1239,17 @@ mod tests { config.location, sent.location, "an absent location keeps the stored one" ); - assert_eq!(config.persona.as_deref(), Some("p")); + assert_eq!( + crate::wayfern_persona::with_edits( + CURRENT_ID, + &serde_json::from_str::>( + config.persona.as_deref().unwrap() + ) + .unwrap() + ), + crate::wayfern_persona::with_edits(&profile.id.to_string(), &[]), + "the converted profile presents the person it presented before" + ); assert!(config.proxy.is_none()); let backup = crate::app_dirs::data_subdir() diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index 63a69194..c9c04307 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -1000,11 +1000,14 @@ impl BrowserLogTap { }) } + /// Whether the browser ran without the profile's stored edits because the + /// plan could not be checked. A plan that does not include them is a settled + /// answer and the launch goes on without them. pub fn dropped_overrides(&self) -> bool { - self - .lines() - .iter() - .any(|line| line.contains("Wayfern launch identity: dropping")) + self.lines().iter().any(|line| { + line.contains("Wayfern launch identity: dropping") + && (line.contains("temporarily unavailable") || line.contains("not known yet")) + }) } /// Whether the browser reported the launch identity as applied. @@ -5753,6 +5756,22 @@ mod tests { ); assert!(tap.dropped_overrides()); assert!(tap.identity_refusal().is_none()); + + let settled = BrowserLogTap::default(); + settled.push( + "[WARNING] Wayfern launch identity: dropping 3 override(s); this plan does not \ + include custom fingerprints. The identity is applied as derived." + .to_string(), + ); + assert!(!settled.dropped_overrides()); + + let unknown = BrowserLogTap::default(); + unknown.push( + "[WARNING] Wayfern launch identity: dropping the stored profile's custom values \ + for this launch; the plan is not known yet. The identity is applied as derived." + .to_string(), + ); + assert!(unknown.dropped_overrides()); } #[test] diff --git a/src-tauri/src/wayfern_persona.rs b/src-tauri/src/wayfern_persona.rs index a146350f..1c4d5731 100644 --- a/src-tauri/src/wayfern_persona.rs +++ b/src-tauri/src/wayfern_persona.rs @@ -242,6 +242,24 @@ pub fn with_edits(seed: &str, edits: &[PersonaField]) -> Vec { fields } +/// The stored edits that keep the person `seed` gives today once the profile +/// derives its persona from another seed: every field as it reads now, and a +/// blank edit for each derived field the user removed. +pub fn pinned_edits(seed: &str, edits: Option<&str>) -> Option { + let edits: Vec = edits + .map(str::trim) + .filter(|edits| !edits.is_empty()) + .and_then(|edits| serde_json::from_str(edits).ok()) + .unwrap_or_default(); + let mut pinned = with_edits(seed, &edits); + for id in FIELD_IDS { + if !pinned.iter().any(|field| field.id == id) { + pinned.push(field(id, "", String::new())); + } + } + serde_json::to_string(&pinned).ok() +} + fn truncate_chars(text: &mut String, limit: usize) { if text.chars().count() > limit { *text = text.chars().take(limit).collect();