fix: cookie key diagnostics

This commit is contained in:
zhom committed 2026-11-03 22:30:35 +04:00
1 parent 2c66a3d5aa
commit 4e0e637559
13 files changed
+825 -89

No files matched your search

+4 -1
View File
@@ -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(
+78 -1
View File
@@ -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,
+433
View File
@@ -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<String, u64>,
/// `None` when there is no encrypted row.
pub oldest_ok: Option<bool>,
pub newest_ok: Option<bool>,
}
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<CookieStore> {
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<u8> = 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<String, String> = 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), &current) {
(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::<BTreeMap<String, String>>(&raw) else {
return;
};
if stamps.remove(profile_id).is_some() {
save_stamps(&path, &stamps);
}
}
fn save_stamps(path: &Path, stamps: &BTreeMap<String, String>) {
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<bool>) -> &'static str {
match value {
Some(true) => "true",
Some(false) => "false",
None => "-",
}
}
fn tags(undecryptable: &BTreeMap<String, u64>) -> String {
if undecryptable.is_empty() {
return "-".to_string();
}
undecryptable
.iter()
.map(|(tag, count)| format!("{tag}:{count}"))
.collect::<Vec<_>>()
.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<u8> {
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<u8>)]) {
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=-"));
}
}
+3 -1
View File
@@ -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 {
+1
View File
@@ -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;
+5
View File
@@ -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}\"");
+1
View File
@@ -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;
+88
View File
@@ -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` (`<profiles dir>/<id>`) 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());
}
}
+1
View File
@@ -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)
})
+1
View File
@@ -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
+39 -1
View File
@@ -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<super::manifest::ManifestFileEntry> = 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(
+96
View File
@@ -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<String> = 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
+75 -85
View File
@@ -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<log::Level> {
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<u8> = 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 {