From 4e0e63755925ed8f65d504d606a60c306d595f75 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Tue, 3 Nov 2026 22:30:35 +0400 Subject: [PATCH] fix: cookie key diagnostics --- e2e/tests/browser.test.mjs | 5 +- e2e/tests/sync.test.mjs | 79 +++++- src-tauri/src/cookie_health.rs | 433 ++++++++++++++++++++++++++++++ src-tauri/src/diagnostics.rs | 4 +- src-tauri/src/lib.rs | 1 + src-tauri/src/profile/manager.rs | 5 + src-tauri/src/profile/mod.rs | 1 + src-tauri/src/profile/origin.rs | 88 ++++++ src-tauri/src/profile/portable.rs | 1 + src-tauri/src/profile_importer.rs | 1 + src-tauri/src/sync/engine.rs | 40 ++- src-tauri/src/sync/manifest.rs | 96 +++++++ src-tauri/src/wayfern_manager.rs | 160 ++++++----- 13 files changed, 825 insertions(+), 89 deletions(-) create mode 100644 src-tauri/src/cookie_health.rs create mode 100644 src-tauri/src/profile/origin.rs diff --git a/e2e/tests/browser.test.mjs b/e2e/tests/browser.test.mjs index f249a013..cb5afeaf 100644 --- a/e2e/tests/browser.test.mjs +++ b/e2e/tests/browser.test.mjs @@ -3267,7 +3267,10 @@ test("a password-protected profile keeps what each session saved", async () => { .map((n) => readFile(path.join(logDir, n), "utf8")), ) ).join("\n"); - assert.doesNotMatch(logText, /Cookie decryption check failed/); + assert.doesNotMatch( + logText, + /Cookie store before launch .*(undecryptable=[1-9]|newest_ok=false|key_changed=yes)/, + ); assert.doesNotMatch(logText, /Profile re-encryption failed/); assert.equal( logText.match( diff --git a/e2e/tests/sync.test.mjs b/e2e/tests/sync.test.mjs index 306f7ad4..b53d5737 100644 --- a/e2e/tests/sync.test.mjs +++ b/e2e/tests/sync.test.mjs @@ -1,5 +1,12 @@ import assert from "node:assert/strict"; -import { mkdir, readdir, readFile, utimes, writeFile } from "node:fs/promises"; +import { + mkdir, + readdir, + readFile, + rm, + utimes, + writeFile, +} from "node:fs/promises"; import { createServer } from "node:http"; import path from "node:path"; import test from "node:test"; @@ -347,6 +354,8 @@ test("two real app devices reconcile profile files and every config entity with path.join(profileData, "Preferences"), JSON.stringify({ donutE2E: "regular-profile-payload" }), ); + const cookieKey = Buffer.alloc(32, 7); + await writeFile(path.join(profileData, "..", "os_crypt_key"), cookieKey); await deviceA.invoke("update_profile_tags", { profileId: profile.id, tags: ["sync", "device-a"], @@ -379,6 +388,7 @@ test("two real app devices reconcile profile files and every config entity with `extension_groups/${extensionGroup.id}.json`, `profiles/${profile.id}/manifest.json`, `profiles/${profile.id}/files/profile/Default/Preferences`, + `profiles/${profile.id}/files/profile/os_crypt_key`, ].every((key) => keys.includes(key)); }, "all regular entities uploaded", @@ -425,6 +435,22 @@ test("two real app devices reconcile profile files and every config entity with ).includes("regular-profile-payload"), "device B receives profile browser files", ); + const profileDirB = path.join( + deviceB.dataRoot, + "data", + "profiles", + profile.id, + ); + assert.deepEqual( + await readFile(path.join(profileDirB, "profile", "os_crypt_key")), + cookieKey, + ); + assert.equal( + ( + await readFile(path.join(profileDirB, ".donut-sync", "origin"), "utf8") + ).trim(), + "synced_down", + ); // updated_at has one-second resolution. Make the device-B edits // unambiguously newer, then verify last-write-wins in both directions. @@ -455,6 +481,57 @@ test("two real app devices reconcile profile files and every config entity with "newer device-B edits win on device A", ); + // Device B publishes a newer copy without the key file. Device A takes the + // newer files but keeps its key, which every cookie depends on, and puts + // it back in the cloud. + const remoteKey = `profiles/${profile.id}/files/profile/os_crypt_key`; + await rm(path.join(profileDirB, "profile", "os_crypt_key")); + await writeFile( + path.join(profileDirB, "profile", "Default", "Preferences"), + JSON.stringify({ donutE2E: "device-b-without-key" }), + ); + await deviceB.invoke("request_profile_sync", { profileId: profile.id }); + await waitFor( + deviceB, + async () => + !(await listRemote("")).some((object) => object.key === remoteKey), + "device B's copy without the key reaches the cloud", + ); + await deviceA.invoke("request_profile_sync", { profileId: profile.id }); + await waitFor( + deviceA, + async () => + ( + await readFile(path.join(profileData, "Preferences"), "utf8").catch( + () => "", + ) + ).includes("device-b-without-key") && + (await listRemote("")).some((object) => object.key === remoteKey), + "device A takes the newer files and restores the key in the cloud", + ); + assert.deepEqual( + await readFile(path.join(profileData, "..", "os_crypt_key")), + cookieKey, + ); + const logDirA = path.join(deviceA.dataRoot, "logs"); + const keptLine = new RegExp( + `Profile synced profile=${profile.id} .*key_files=os_crypt_key:kept`, + ); + await waitFor( + deviceA, + async () => + ( + await Promise.all( + ( + await readdir(logDirA) + ) + .filter((name) => name.endsWith(".log")) + .map((name) => readFile(path.join(logDirA, name), "utf8")), + ) + ).some((text) => keptLine.test(text)), + "device A logs that it kept the key file", + ); + assert.equal( await deviceA.invoke("is_proxy_in_use_by_synced_profile", { proxyId: proxy.id, diff --git a/src-tauri/src/cookie_health.rs b/src-tauri/src/cookie_health.rs new file mode 100644 index 00000000..3a93e188 --- /dev/null +++ b/src-tauri/src/cookie_health.rs @@ -0,0 +1,433 @@ +//! Whether a Wayfern profile's key file still opens its cookies, checked +//! before each launch. +//! +//! The browser drops every cookie its key cannot open, so a key file that +//! changed or went missing since the last launch logs a person out of every +//! site. The line this writes says which: the key file's state, whether it +//! changed since this device last launched the profile, and whether the +//! oldest and the newest encrypted cookie open with it. It never logs the key, +//! a hash of it, or a cookie. + +use crate::profile_import::os_crypt; +use std::collections::BTreeMap; +use std::path::Path; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyFile { + Missing, + Blank, + Present(u64), +} + +impl KeyFile { + fn of(contents: Option<&[u8]>) -> Self { + match contents { + None => Self::Missing, + Some(bytes) if bytes.iter().all(|byte| *byte == 0) => Self::Blank, + Some(bytes) => Self::Present(bytes.len() as u64), + } + } + + fn label(self) -> &'static str { + match self { + Self::Missing => "missing", + Self::Blank => "blank", + Self::Present(_) => "present", + } + } + + fn bytes(self) -> u64 { + match self { + Self::Present(bytes) => bytes, + _ => 0, + } + } +} + +/// Whether the key file changed since the last launch on this device. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyChange { + /// No launch recorded yet. + First, + Same, + Changed, +} + +impl KeyChange { + fn label(self) -> &'static str { + match self { + Self::First => "first", + Self::Same => "no", + Self::Changed => "yes", + } + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct CookieStore { + pub cookies: u64, + pub encrypted: u64, + /// Encrypted rows the key file does not open, by their 3-byte tag. + pub undecryptable: BTreeMap, + /// `None` when there is no encrypted row. + pub oldest_ok: Option, + pub newest_ok: Option, +} + +impl CookieStore { + pub fn undecryptable_total(&self) -> u64 { + self.undecryptable.values().sum() + } +} + +/// Whether `key` opens one stored `encrypted_value`. Only the cipher is +/// checked, not the value inside: a value that is not UTF-8 still opened. +fn row_opens(key: Option<&[u8]>, encrypted: &[u8]) -> bool { + let Some(key) = key else { + return false; + }; + if encrypted.len() < 3 { + return false; + } + let (tag, ciphertext) = encrypted.split_at(3); + let Some(primary) = os_crypt::key_for_tag(key, tag) else { + return false; + }; + ciphertext.is_empty() + || primary + .with_fallbacks() + .iter() + .any(|candidate| candidate.decrypt(ciphertext).is_some()) +} + +fn tag_label(encrypted: &[u8]) -> String { + match encrypted.get(..3) { + Some(tag) if tag.iter().all(u8::is_ascii_alphanumeric) => { + String::from_utf8_lossy(tag).into_owned() + } + _ => "other".to_string(), + } +} + +/// Read the cookie store at `cookies_path` and test every encrypted row +/// against `key`. `None` when the database does not open. +pub fn inspect_cookies(cookies_path: &Path, key: Option<&[u8]>) -> Option { + let conn = + rusqlite::Connection::open_with_flags(cookies_path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY) + .ok()?; + let mut store = CookieStore { + cookies: conn + .query_row("SELECT COUNT(*) FROM cookies", [], |row| { + row.get::<_, i64>(0) + }) + .ok()? + .max(0) as u64, + ..CookieStore::default() + }; + let mut stmt = conn + .prepare( + "SELECT encrypted_value FROM cookies WHERE length(encrypted_value) > 0 ORDER BY creation_utc, rowid", + ) + .ok()?; + let mut rows = stmt.query([]).ok()?; + let mut last_ok = None; + while let Ok(Some(row)) = rows.next() { + let encrypted: Vec = row.get(0).unwrap_or_default(); + let ok = row_opens(key, &encrypted); + if !ok { + *store + .undecryptable + .entry(tag_label(&encrypted)) + .or_default() += 1; + } + if store.oldest_ok.is_none() { + store.oldest_ok = Some(ok); + } + last_ok = Some(ok); + store.encrypted += 1; + } + store.newest_ok = last_ok; + Some(store) +} + +fn key_stamp(install_key: &[u8], contents: &[u8]) -> String { + use ring::hmac; + let derived = hmac::sign( + &hmac::Key::new(hmac::HMAC_SHA256, install_key), + b"donut cookie key stamp", + ); + let tag = hmac::sign( + &hmac::Key::new(hmac::HMAC_SHA256, derived.as_ref()), + contents, + ); + tag + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Compare the key file with the stamp kept for `profile_id` in +/// `stamps_file`, and keep the new stamp. A missing key file keeps the old +/// stamp, so the key that comes back is compared with the one that was lost. +pub fn key_change( + stamps_file: &Path, + install_key: &[u8], + profile_id: &str, + contents: Option<&[u8]>, +) -> KeyChange { + let mut stamps: BTreeMap = std::fs::read(stamps_file) + .ok() + .and_then(|raw| serde_json::from_slice(&raw).ok()) + .unwrap_or_default(); + let current = contents + .filter(|bytes| KeyFile::of(Some(bytes)) != KeyFile::Blank) + .map(|bytes| key_stamp(install_key, bytes)); + let change = match (stamps.get(profile_id), ¤t) { + (None, _) => KeyChange::First, + (Some(old), Some(new)) if old == new => KeyChange::Same, + _ => KeyChange::Changed, + }; + if let Some(new) = current { + if stamps.get(profile_id) != Some(&new) { + stamps.insert(profile_id.to_string(), new); + save_stamps(stamps_file, &stamps); + } + } + change +} + +/// Forget a deleted profile's stamp. +pub fn forget(profile_id: &str) { + let path = stamps_file(); + let Ok(raw) = std::fs::read(&path) else { + return; + }; + let Ok(mut stamps) = serde_json::from_slice::>(&raw) else { + return; + }; + if stamps.remove(profile_id).is_some() { + save_stamps(&path, &stamps); + } +} + +fn save_stamps(path: &Path, stamps: &BTreeMap) { + let Ok(json) = serde_json::to_vec(stamps) else { + return; + }; + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let staging = path.with_extension("json.tmp"); + if std::fs::write(&staging, json).is_ok() && std::fs::rename(&staging, path).is_ok() { + crate::app_dirs::restrict_to_owner(path); + } +} + +fn stamps_file() -> std::path::PathBuf { + crate::app_dirs::settings_dir().join("cookie_key_stamps.json") +} + +fn flag(value: Option) -> &'static str { + match value { + Some(true) => "true", + Some(false) => "false", + None => "-", + } +} + +fn tags(undecryptable: &BTreeMap) -> String { + if undecryptable.is_empty() { + return "-".to_string(); + } + undecryptable + .iter() + .map(|(tag, count)| format!("{tag}:{count}")) + .collect::>() + .join(",") +} + +/// The line about a profile's key file and cookies, and its level. +pub fn report( + profile_id: &str, + key: KeyFile, + change: KeyChange, + store: Option<&CookieStore>, +) -> (log::Level, String) { + let default = CookieStore::default(); + let store = store.unwrap_or(&default); + let undecryptable = store.undecryptable_total(); + let level = + if store.encrypted > 0 && (store.newest_ok == Some(false) || key != KeyFile::Present(32)) { + log::Level::Error + } else if change == KeyChange::Changed || undecryptable > 0 { + log::Level::Warn + } else { + log::Level::Debug + }; + ( + level, + format!( + "Cookie store before launch profile={profile_id} key={} key_bytes={} key_changed={} cookies={} encrypted={} undecryptable={undecryptable} undecryptable_tags={} oldest_ok={} newest_ok={}", + key.label(), + key.bytes(), + change.label(), + store.cookies, + store.encrypted, + tags(&store.undecryptable), + flag(store.oldest_ok), + flag(store.newest_ok), + ), + ) +} + +/// Check a Wayfern profile's key file and cookies before a launch and log +/// what was found. `profile_data_dir` is the browser's user-data dir. +pub fn log_before_launch(profile_id: &str, profile_data_dir: &Path) { + let contents = std::fs::read(profile_data_dir.join(os_crypt::KEY_FILE_NAME)).ok(); + let key = KeyFile::of(contents.as_deref()); + let change = match crate::vault::install_key() { + Ok(install_key) => key_change( + &stamps_file(), + &install_key, + profile_id, + contents.as_deref(), + ), + Err(_) => KeyChange::First, + }; + let cookies_path = crate::cookie_manager::CookieManager::wayfern_cookie_path(profile_data_dir); + let usable_key = contents.as_deref().filter(|_| key != KeyFile::Blank); + let store = cookies_path + .exists() + .then(|| inspect_cookies(&cookies_path, usable_key)) + .flatten(); + let (level, line) = report(profile_id, key, change, store.as_ref()); + log::log!(level, "{line}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn seal(key: &[u8; 32], host: &str, value: &[u8]) -> Vec { + use sha2::{Digest, Sha256}; + let mut plain = Sha256::digest(host.as_bytes()).to_vec(); + plain.extend_from_slice(value); + let mut sealed = b"v10".to_vec(); + sealed.extend( + os_crypt::CryptoKey::Aes256Gcm(*key) + .encrypt(&plain) + .unwrap(), + ); + sealed + } + + fn cookie_db(path: &Path, rows: &[(i64, Vec)]) { + let conn = rusqlite::Connection::open(path).unwrap(); + conn + .execute_batch( + "CREATE TABLE cookies (creation_utc INTEGER, host_key TEXT, name TEXT, value TEXT, encrypted_value BLOB)", + ) + .unwrap(); + for (created, encrypted) in rows { + conn + .execute( + "INSERT INTO cookies VALUES (?1, 'example.com', 'n', '', ?2)", + rusqlite::params![created, encrypted], + ) + .unwrap(); + } + } + + #[test] + fn reports_the_oldest_and_newest_rows_separately() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("Cookies"); + let key = [7u8; 32]; + let other = [9u8; 32]; + // Inserted newest first, so rowid order differs from creation order. + cookie_db( + &db, + &[ + (300, seal(&key, "example.com", b"new")), + (100, seal(&other, "example.com", b"stale")), + (200, seal(&key, "example.com", &[0xE9, 0xFF])), + (250, b"v20garbage-bytes".to_vec()), + ], + ); + let store = inspect_cookies(&db, Some(&key)).unwrap(); + assert_eq!(store.cookies, 4); + assert_eq!(store.encrypted, 4); + assert_eq!(store.oldest_ok, Some(false)); + assert_eq!(store.newest_ok, Some(true)); + assert_eq!( + store.undecryptable, + BTreeMap::from([("v10".to_string(), 1), ("v20".to_string(), 1)]) + ); + let (level, line) = report("p", KeyFile::Present(32), KeyChange::Same, Some(&store)); + assert_eq!(level, log::Level::Warn); + assert!(line + .contains("undecryptable=2 undecryptable_tags=v10:1,v20:1 oldest_ok=false newest_ok=true")); + } + + #[test] + fn a_newest_row_that_does_not_open_is_an_error() { + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("Cookies"); + cookie_db(&db, &[(1, seal(&[1u8; 32], "a.test", b"x"))]); + let store = inspect_cookies(&db, Some(&[2u8; 32])).unwrap(); + let (level, line) = report("p", KeyFile::Present(32), KeyChange::Changed, Some(&store)); + assert_eq!(level, log::Level::Error); + assert!(line.contains("key_changed=yes")); + assert!(line.contains("newest_ok=false")); + let no_key = inspect_cookies(&db, None).unwrap(); + assert_eq!(no_key.undecryptable_total(), 1); + } + + #[test] + fn key_change_tracks_the_file_without_storing_it() { + let dir = tempfile::tempdir().unwrap(); + let stamps = dir.path().join("stamps.json"); + let install = [3u8; 32]; + let key = [5u8; 32]; + assert_eq!( + key_change(&stamps, &install, "p", Some(&key)), + KeyChange::First + ); + assert_eq!( + key_change(&stamps, &install, "p", Some(&key)), + KeyChange::Same + ); + let raw = std::fs::read(&stamps).unwrap(); + assert!(!raw.windows(key.len()).any(|w| w == key)); + // A lost file is a change, and the old stamp stays to compare against. + assert_eq!(key_change(&stamps, &install, "p", None), KeyChange::Changed); + assert_eq!( + key_change(&stamps, &install, "p", Some(&key)), + KeyChange::Same + ); + assert_eq!( + key_change(&stamps, &install, "p", Some(&[6u8; 32])), + KeyChange::Changed + ); + assert_eq!( + key_change(&stamps, &install, "q", Some(&key)), + KeyChange::First + ); + } + + #[test] + fn a_healthy_store_logs_at_debug() { + let store = CookieStore { + cookies: 2, + encrypted: 2, + oldest_ok: Some(true), + newest_ok: Some(true), + ..CookieStore::default() + }; + let (level, line) = report("p", KeyFile::Present(32), KeyChange::Same, Some(&store)); + assert_eq!(level, log::Level::Debug); + assert!(line.contains("key=present key_bytes=32 key_changed=no")); + assert!(line.contains("undecryptable_tags=-")); + } +} diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs index 1c858ceb..4034e2db 100644 --- a/src-tauri/src/diagnostics.rs +++ b/src-tauri/src/diagnostics.rs @@ -250,7 +250,7 @@ fn facts( .unwrap_or_else(|| "-".to_string()); let _ = writeln!( out, - "{} browser={} version={} running={} ephemeral={} proxy={} vpn={} group={} sync={} extensions={}", + "{} browser={} version={} running={} ephemeral={} proxy={} vpn={} group={} sync={} extensions={} clear_on_close={} origin={}", aliases.apply(&profile.id.to_string()), profile.browser, profile.version, @@ -261,6 +261,8 @@ fn facts( profile.group_id.as_deref().map_or("-".to_string(), |id| aliases.apply(id)), sync, profile.extension_group_id.is_some(), + profile.clear_on_close, + crate::profile::origin::read(&profile.id), ); } if profiles.len() > MAX_LISTED_PROFILES { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 74569094..b555db51 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -255,6 +255,7 @@ mod cloud_errors; mod commercial_license; mod cookie_bot; mod cookie_bot_exit; +mod cookie_health; mod cookie_manager; mod cookie_paste; pub mod events; diff --git a/src-tauri/src/profile/manager.rs b/src-tauri/src/profile/manager.rs index de393179..b82b3afe 100644 --- a/src-tauri/src/profile/manager.rs +++ b/src-tauri/src/profile/manager.rs @@ -623,6 +623,7 @@ impl ProfileManager { // Save profile info self.save_profile(&profile)?; + crate::profile::origin::record(&profile.id, crate::profile::origin::Origin::Created); // Verify the profile was saved correctly if !profile_file.exists() { @@ -914,6 +915,9 @@ impl ProfileManager { let started = std::time::Instant::now(); let removed = self.find_profile(profile_id).ok(); let outcome = self.remove_profile_unlogged(app_handle, profile_id, permanent, emit_events)?; + if permanent { + crate::cookie_health::forget(profile_id); + } if let Some(profile) = &removed { report_team_edit(profile, "profile.delete", &[]); } @@ -1831,6 +1835,7 @@ impl ProfileManager { } self.save_profile(&new_profile)?; + crate::profile::origin::record(&new_profile.id, crate::profile::origin::Origin::Cloned); if let Err(e) = events::emit_empty("profiles-changed") { log::warn!("Event emit failed event=profiles-changed err=\"{e}\""); diff --git a/src-tauri/src/profile/mod.rs b/src-tauri/src/profile/mod.rs index 5a141eaa..33095b10 100644 --- a/src-tauri/src/profile/mod.rs +++ b/src-tauri/src/profile/mod.rs @@ -1,6 +1,7 @@ pub mod clear_on_close; pub mod encryption; pub mod manager; +pub mod origin; pub mod password; pub mod patch; pub mod portable; diff --git a/src-tauri/src/profile/origin.rs b/src-tauri/src/profile/origin.rs new file mode 100644 index 00000000..cb64f90a --- /dev/null +++ b/src-tauri/src/profile/origin.rs @@ -0,0 +1,88 @@ +//! How a profile came to this device: made here, cloned, imported, or +//! downloaded by sync. Kept per device in the profile's `.donut-sync` folder, +//! which sync never uploads, because the same profile has a different origin +//! on each device. Profiles from before this record read `unknown`. + +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Origin { + Created, + Cloned, + Imported, + SyncedDown, +} + +impl Origin { + fn label(self) -> &'static str { + match self { + Self::Created => "created", + Self::Cloned => "cloned", + Self::Imported => "imported", + Self::SyncedDown => "synced_down", + } + } +} + +fn origin_file(profile_dir: &Path) -> PathBuf { + profile_dir.join(".donut-sync").join("origin") +} + +/// Note where the profile in `profile_dir` (`/`) came +/// from. Written when the profile appears here, over any record a copied +/// directory brought along. +pub fn record_in(profile_dir: &Path, origin: Origin) { + let file = origin_file(profile_dir); + if let Some(parent) = file.parent() { + let _ = std::fs::create_dir_all(parent); + } + let _ = std::fs::write(file, origin.label()); +} + +pub fn read_in(profile_dir: &Path) -> &'static str { + match std::fs::read_to_string(origin_file(profile_dir)) + .unwrap_or_default() + .trim() + { + "created" => "created", + "cloned" => "cloned", + "imported" => "imported", + "synced_down" => "synced_down", + _ => "unknown", + } +} + +pub fn record(profile_id: &uuid::Uuid, origin: Origin) { + record_in( + &crate::app_dirs::profiles_dir().join(profile_id.to_string()), + origin, + ); +} + +pub fn read(profile_id: &uuid::Uuid) -> &'static str { + read_in(&crate::app_dirs::profiles_dir().join(profile_id.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_unknown_without_a_record_and_the_latest_record_with_one() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(read_in(dir.path()), "unknown"); + record_in(dir.path(), Origin::SyncedDown); + assert_eq!(read_in(dir.path()), "synced_down"); + record_in(dir.path(), Origin::Cloned); + assert_eq!(read_in(dir.path()), "cloned"); + } + + #[test] + fn the_record_is_outside_what_sync_uploads() { + let dir = tempfile::tempdir().unwrap(); + record_in(dir.path(), Origin::Imported); + let mut cache = crate::sync::manifest::HashCache::default(); + let manifest = crate::sync::manifest::generate_manifest("p", dir.path(), &mut cache).unwrap(); + assert!(manifest.files.is_empty()); + } +} diff --git a/src-tauri/src/profile/portable.rs b/src-tauri/src/profile/portable.rs index 26ff3a70..6b3dec8d 100644 --- a/src-tauri/src/profile/portable.rs +++ b/src-tauri/src/profile/portable.rs @@ -598,6 +598,7 @@ pub async fn import_profile_archive( let _ = fs::remove_dir_all(data_dir.parent().unwrap_or(&data_dir)); import_err(format!("could not save it: {e}")) })?; + crate::profile::origin::record(&profile.id, crate::profile::origin::Origin::Imported); let _ = crate::events::emit("profiles-changed", serde_json::json!({})); Ok(profile) }) diff --git a/src-tauri/src/profile_importer.rs b/src-tauri/src/profile_importer.rs index 222aff70..234f0daf 100644 --- a/src-tauri/src/profile_importer.rs +++ b/src-tauri/src/profile_importer.rs @@ -1202,6 +1202,7 @@ impl ProfileImporter { }; self.profile_manager.save_profile(&profile)?; + crate::profile::origin::record(&profile.id, crate::profile::origin::Origin::Imported); if report.is_empty_import() { // Not an error — an empty source profile imports legitimately — but it is diff --git a/src-tauri/src/sync/engine.rs b/src-tauri/src/sync/engine.rs index 3ad9b92f..b3c08c11 100644 --- a/src-tauri/src/sync/engine.rs +++ b/src-tauri/src/sync/engine.rs @@ -1137,6 +1137,9 @@ impl SyncEngine { log::warn!("Unsafe manifest path, local delete skipped path={path:?}"); continue; } + if pending_session.is_none() && super::manifest::is_key_material(path) { + continue; + } let file_path = profile_dir.join(path); if file_path.exists() { let _ = fs::remove_file(&file_path); @@ -1144,6 +1147,37 @@ impl SyncEngine { } } + // The remote copy lacks a key file this device has. Deleting it would lose + // every encrypted cookie, so it stays and goes up with the rest. + let kept_keys: Vec = local_manifest + .files + .iter() + .filter(|f| { + pending_session.is_none() + && super::manifest::is_key_material(&f.path) + && diff.files_to_delete_local.contains(&f.path) + }) + .cloned() + .collect(); + if !kept_keys.is_empty() { + log::warn!( + "Remote copy lacks key files, local kept profile={profile_id} files={}", + kept_keys.len() + ); + self + .upload_profile_files( + app_handle, + &profile_id, + &profile.name, + &profile_dir, + &kept_keys, + encryption_key.as_ref(), + &key_prefix, + &cancel_flag, + ) + .await?; + } + // Delete remote files that don't exist locally (when local is newer) for path in &diff.files_to_delete_remote { let remote_key = format!("{}profiles/{}/files/{}", key_prefix, profile_id, path); @@ -1158,6 +1192,8 @@ impl SyncEngine { // devices ping-pong re-uploads forever. Regenerate from the // actual on-disk files before uploading. When only uploads happened, the // on-disk state is unchanged and the original manifest is still accurate. + let key_files = + super::manifest::key_file_changes(&diff, &local_manifest, pending_session.is_none()); let local_changed = !diff.files_to_download.is_empty() || !diff.files_to_delete_local.is_empty(); let final_manifest = if local_changed { @@ -1248,7 +1284,7 @@ impl SyncEngine { crate::cookie_bot::report_profile_state(&updated_profile); log::info!( - "Profile synced profile={profile_id} uploaded={} downloaded={} deleted_local={} deleted_remote={} bytes_up={upload_bytes} bytes_down={download_bytes} elapsed_ms={}", + "Profile synced profile={profile_id} uploaded={} downloaded={} deleted_local={} deleted_remote={} key_files={key_files} bytes_up={upload_bytes} bytes_down={download_bytes} elapsed_ms={}", diff.files_to_upload.len(), diff.files_to_download.len(), diff.files_to_delete_local.len(), @@ -3072,6 +3108,7 @@ impl SyncEngine { profile_manager .save_profile(&profile) .map_err(|e| SyncError::IoError(format!("Failed to save cross-OS profile: {e}")))?; + crate::profile::origin::record_in(&profile_dir, crate::profile::origin::Origin::SyncedDown); let _ = events::emit("profiles-changed", ()); let _ = events::emit( @@ -3179,6 +3216,7 @@ impl SyncEngine { profile_manager .save_profile(&profile) .map_err(|e| SyncError::IoError(format!("Failed to save downloaded profile: {e}")))?; + crate::profile::origin::record_in(&profile_dir, crate::profile::origin::Origin::SyncedDown); let _ = events::emit("profiles-changed", ()); let _ = events::emit( diff --git a/src-tauri/src/sync/manifest.rs b/src-tauri/src/sync/manifest.rs index 0264f4a3..07a079c2 100644 --- a/src-tauri/src/sync/manifest.rs +++ b/src-tauri/src/sync/manifest.rs @@ -451,6 +451,56 @@ pub enum DiffBias { Restore, } +/// The files that hold a Wayfern profile's cookies and the key that opens +/// them, by manifest path. A sync that moves one without the other logs the +/// person out of every site, so the result line names what happened to each. +const KEY_FILES: &[(&str, &str)] = &[ + ("profile/os_crypt_key", "os_crypt_key"), + ("profile/Local State", "Local State"), + ("profile/Default/Network/Cookies", "Cookies"), + ("profile/Default/Cookies", "Cookies"), +]; + +/// Key material a sync never deletes locally: without it every encrypted +/// cookie and password in the profile is lost, and a remote copy that lacks it +/// cannot replace it. +pub fn is_key_material(path: &str) -> bool { + matches!(path, "profile/os_crypt_key" | "profile/Local State") +} + +/// `os_crypt_key:downloaded,Cookies:replaced`: what `diff` does to the +/// profile's key files on this device, or `-` when it touches none. A download +/// over a file `local` has is `replaced`; key material the sync keeps instead +/// of deleting (`keeps_keys`) is `kept`. +pub fn key_file_changes(diff: &ManifestDiff, local: &SyncManifest, keeps_keys: bool) -> String { + let changes: Vec = KEY_FILES + .iter() + .filter_map(|(path, name)| { + let action = if diff.files_to_download.iter().any(|f| f.path == *path) { + if local.files.iter().any(|f| f.path == *path) { + "replaced" + } else { + "downloaded" + } + } else if diff.files_to_delete_local.iter().any(|p| p == path) { + if keeps_keys && is_key_material(path) { + "kept" + } else { + "deleted" + } + } else { + return None; + }; + Some(format!("{name}:{action}")) + }) + .collect(); + if changes.is_empty() { + "-".to_string() + } else { + changes.join(",") + } +} + pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> ManifestDiff { compute_diff_with_bias(local, remote, DiffBias::Auto) } @@ -830,6 +880,52 @@ mod tests { } } + #[test] + fn key_file_changes_name_what_a_sync_does_to_the_key_and_cookies() { + let local = manifest_at( + "2026-01-01T00:00:00Z", + &[ + ("profile/os_crypt_key", "k1"), + ("profile/Local State", "ls"), + ("profile/Default/Network/Cookies", "c1"), + ("profile/Default/History", "h1"), + ], + ); + let remote = manifest_at( + "2026-01-02T00:00:00Z", + &[ + ("profile/Default/Network/Cookies", "c2"), + ("profile/Default/History", "h2"), + ], + ); + let diff = compute_diff(&local, Some(&remote)); + assert_eq!( + key_file_changes(&diff, &local, true), + "os_crypt_key:kept,Local State:kept,Cookies:replaced" + ); + assert_eq!( + key_file_changes(&diff, &local, false), + "os_crypt_key:deleted,Local State:deleted,Cookies:replaced" + ); + + let empty = manifest_at("2026-01-01T00:00:00Z", &[("profile/x", "1")]); + let fresh = manifest_at( + "2026-01-02T00:00:00Z", + &[("profile/x", "1"), ("profile/os_crypt_key", "k")], + ); + let diff = compute_diff(&empty, Some(&fresh)); + assert_eq!( + key_file_changes(&diff, &empty, true), + "os_crypt_key:downloaded" + ); + assert_eq!( + key_file_changes(&compute_diff(&fresh, Some(&fresh)), &fresh, true), + "-" + ); + assert!(is_key_material("profile/os_crypt_key")); + assert!(!is_key_material("profile/Default/Network/Cookies")); + } + #[test] fn prefer_remote_downloads_even_though_the_local_clock_is_later() { // The exact shape of the data-loss bug. A remote session finishes and the diff --git a/src-tauri/src/wayfern_manager.rs b/src-tauri/src/wayfern_manager.rs index fdafefce..8aaa7245 100644 --- a/src-tauri/src/wayfern_manager.rs +++ b/src-tauri/src/wayfern_manager.rs @@ -1021,6 +1021,38 @@ impl BrowserLogTap { } } +/// The level a browser stderr line is logged at, or `None` to drop it. Only +/// what the browser says about Wayfern, its own errors, and its cookie key +/// (`os_crypt`) are kept. +fn browser_line_level(line: &str) -> Option { + if line.contains("os_crypt") { + // The browser could not read or write the key that seals cookies: the + // profile is about to lose its sign-ins. + return Some(log::Level::Warn); + } + // Chromium's own errors are kept too: a browser that quits before its + // control port opens says why only there. + if line.contains("Wayfern") + || line.contains("wayfern_") + || line.contains(":ERROR:") + || line.contains(":FATAL:") + { + // What the browser did with the identity explains a profile that + // launched without one; the rest is detail. + return Some(if line.contains("Wayfern launch identity") { + log::Level::Info + } else { + log::Level::Debug + }); + } + None +} + +/// One `os_crypt` line per profile a minute: a browser that cannot open its +/// key says so for every cookie. +static OS_CRYPT_LINES: crate::log_streak::Throttle = + crate::log_streak::Throttle::new(Duration::from_secs(60)); + /// Keep reading the browser's stderr for its lifetime, keeping only what it /// says about Wayfern. Reading it all is what keeps the pipe from filling. /// `owner` is `key=value` text naming whose browser this is. @@ -1029,27 +1061,27 @@ fn tap_browser_stderr(stderr: tokio::process::ChildStderr, tap: BrowserLogTap, o use tokio::io::{AsyncBufReadExt, BufReader}; let mut lines = BufReader::new(stderr).lines(); while let Ok(Some(line)) = lines.next_line().await { - // Chromium's own errors are kept too: a browser that quits before its - // control port opens says why only there. - if line.contains("Wayfern") - || line.contains("wayfern_") - || line.contains(":ERROR:") - || line.contains(":FATAL:") - { - // What the browser did with the identity explains a profile that - // launched without one; the rest is detail. - let level = if line.contains("Wayfern launch identity") { - log::Level::Info - } else { - log::Level::Debug - }; + let Some(level) = browser_line_level(&line) else { + continue; + }; + let text = crate::log_redaction::text(&line); + if level == log::Level::Warn { + let key = owner.split_whitespace().next().unwrap_or_default(); + if let Some(dropped) = OS_CRYPT_LINES.allow(key) { + log::warn!( + "Browser cookie key error {owner} line=\"{}\"{}", + crate::log_redaction::Plain(&text), + crate::log_streak::suppressed(dropped) + ); + } + } else { log::log!( level, "Browser said {owner} line=\"{}\"", - crate::log_redaction::Plain(&crate::log_redaction::text(&line)) + crate::log_redaction::Plain(&text) ); - tap.push(line); } + tap.push(line); } }); } @@ -3065,75 +3097,10 @@ impl WayfernManager { None => Self::find_free_port().await?, }; - // Diagnostic: verify critical profile files and test cookie decryption - { - let profile_path_buf = std::path::PathBuf::from(profile_path); - let key_path = profile_path_buf.join("os_crypt_key"); - let cookies_path = - crate::cookie_manager::CookieManager::wayfern_cookie_path(&profile_path_buf); - - // Length only. The contents are the profile's encryption key, and this - // log is the first thing a user attaches to a bug report. - let key_len = std::fs::metadata(&key_path).map(|m| m.len()).ok(); - let mut cookie_counts = None; - let mut decryption = "not_checked"; - - if cookies_path.exists() { - // Try to open Cookies DB and check if encrypted cookies can be decrypted - if let Ok(conn) = rusqlite::Connection::open_with_flags( - &cookies_path, - rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY, - ) { - let cookie_count: i64 = conn - .query_row( - "SELECT COUNT(*) FROM cookies WHERE length(encrypted_value) > 0", - [], - |r| r.get(0), - ) - .unwrap_or(0); - let total_count: i64 = conn - .query_row("SELECT COUNT(*) FROM cookies", [], |r| r.get(0)) - .unwrap_or(0); - cookie_counts = Some((total_count, cookie_count)); - - // Try decrypting one cookie using the cookie_manager - if let Some(encryption_key) = - crate::cookie_manager::chrome_decrypt::get_encryption_key(&profile_path_buf) - { - if let Ok(mut stmt) = conn.prepare( - "SELECT name, host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 0 LIMIT 1", - ) { - if let Ok(mut rows) = stmt.query([]) { - if let Ok(Some(row)) = rows.next() { - let host: String = row.get(1).unwrap_or_default(); - let encrypted: Vec = row.get(2).unwrap_or_default(); - let decrypted = crate::cookie_manager::chrome_decrypt::decrypt( - &encrypted, - &host, - &encryption_key, - ); - decryption = if decrypted.is_some() { "ok" } else { "failed" }; - } - } - } - } else { - decryption = "no_key"; - } - } - } - - let (cookies, encrypted) = cookie_counts.unwrap_or_default(); - log::debug!( - "Cookie store before launch profile={} os_crypt_key_bytes={key_len:?} cookies={cookies} encrypted={encrypted} decryption={decryption}", - profile.id - ); - if encrypted > 0 && matches!(decryption, "failed" | "no_key") { - log::error!( - "Cookie decryption check failed profile={} encrypted={encrypted} reason={decryption}", - profile.id - ); - } - } + crate::cookie_health::log_before_launch( + &profile.id.to_string(), + std::path::Path::new(profile_path), + ); let mut args = vec![ format!("--remote-debugging-port={port}"), @@ -4412,6 +4379,29 @@ fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) { #[cfg(test)] mod tests { + #[test] + fn only_wayfern_errors_and_cookie_key_lines_leave_the_browser() { + use super::browser_line_level; + assert_eq!( + browser_line_level( + "[1:2:1102/0:ERROR:components\\os_crypt\\sync\\os_crypt_win.cc:90] Failed to decrypt" + ), + Some(log::Level::Warn) + ); + assert_eq!( + browser_line_level("[1:2:INFO:wayfern_launch_identity.cc:1] Wayfern launch identity applied"), + Some(log::Level::Info) + ); + assert_eq!( + browser_line_level("[1:2:ERROR:gpu_init.cc:1] no GPU"), + Some(log::Level::Debug) + ); + assert_eq!( + browser_line_level("[1:2:INFO:visited.cc:1] https://example.com"), + None + ); + } + #[test] fn identity_location_carries_the_exit_for_create_identity() { let geo = crate::geolocation::Geolocation {