refactor: profile imports

This commit is contained in:
zhom
2026-08-10 09:19:25 +04:00
parent a6b79341b3
commit 32a1728dee
33 changed files with 4917 additions and 387 deletions
+551
View File
@@ -0,0 +1,551 @@
//! Copying a source profile into the new one.
//!
//! Two things a plain recursive copy gets wrong, both of which produce a
//! profile that looks imported and is not:
//!
//! - **Torn databases.** Users import from a browser they are still using. A
//! naive walk copies `Cookies` and `Cookies-wal` at different instants, and
//! Chromium's `sql::Database` razes the result on open. `VACUUM INTO` takes a
//! transactionally consistent snapshot instead, WAL content included, even
//! while the source holds the file.
//! - **Multi-GB of caches.** `Cache/`, `Code Cache/`, `GPUCache/` and friends
//! carry no user state and dominate both copy time and disk use.
use std::fs;
use std::path::Path;
/// Directories that never carry user state. Matched on the path relative to the
/// profile root, so `Service Worker/CacheStorage` is dropped while
/// `Service Worker/Database` survives.
const SKIP_DIRS: &[&str] = &[
"Cache",
"Code Cache",
"GPUCache",
"GrShaderCache",
"ShaderCache",
"DawnCache",
"DawnGraphiteCache",
"DawnWebGPUCache",
"GraphiteDawnCache",
"GPUPersistentCache",
"Service Worker/CacheStorage",
"Service Worker/ScriptCache",
"blob_storage",
"Crashpad",
"Crash Reports",
"BrowserMetrics",
"optimization_guide_model_store",
"optimization_guide_hint_cache_store",
"Safe Browsing",
"Safe Browsing Network",
"component_crx_cache",
"extensions_crx_cache",
"Download Service",
"Site Characteristics Database",
"shared_proto_db",
"segmentation_platform",
"Sync App Settings",
// SNSS command logs replay the source machine's windows and can embed
// absolute local paths in PageState blobs.
"Sessions",
"Session Storage",
];
/// Exact file names that are per-machine, per-run, or regenerated.
const SKIP_FILES: &[&str] = &[
"LOCK",
"LOG",
"LOG.old",
"SingletonLock",
"SingletonCookie",
"SingletonSocket",
"RunningChromeVersion",
"Last Version",
"first_party_sets.db",
".DS_Store",
"Thumbs.db",
// The account-bound part of `Sync Data/`. The rest of that directory is the
// local DataTypeStore — Reading List, Saved Tab Groups and friends, which
// exist for users who never signed in — so the folder itself is carried.
"Nigori.bin",
// Signed-in ephemeral twins of the real stores. They are wiped on sign-out,
// and the imported profile will not be signed in.
"Login Data For Account",
"Login Data For Account-journal",
"Account Web Data",
"Account Web Data-journal",
];
/// Suffixes that belong to a database we snapshot separately, or to scratch
/// state. Copying a `-wal` next to a vacuumed main file actively corrupts it.
const SKIP_SUFFIXES: &[&str] = &["-journal", "-wal", "-shm", ".tmp", ".old", ".bak.tmp"];
/// SQLite stores worth a consistent snapshot. Anything not listed is copied
/// byte-for-byte, which is correct for JSON, LevelDB and unpacked CRXs.
const SQLITE_FILES: &[&str] = &[
"Cookies",
"History",
"Favicons",
"Top Sites",
"Shortcuts",
"Login Data",
"Web Data",
"Affiliation Database",
"Network Action Predictor",
"DIPS",
"Trust Tokens",
"BudgetDatabase",
"AutofillStrikeDatabase",
"Reporting and NEL",
"SCT Auditing Pending Reports",
"Device Bound Sessions",
"MediaDeviceSalts",
"PreferredApps",
"heavy_ad_intervention_opt_out.db",
"SharedStorage",
"BrowsingTopicsSiteData",
"ClientCertificates",
"PersistentOriginTrials",
"Web Applications",
];
pub struct CopyOutcome {
pub bytes_copied: u64,
/// Names of stores that could not be snapshotted and were skipped rather
/// than copied in a corrupt state.
pub unreadable_stores: Vec<String>,
}
fn is_skipped_dir(relative: &Path) -> bool {
let normalized = relative.to_string_lossy().replace('\\', "/");
SKIP_DIRS.iter().any(|skip| {
normalized == *skip
|| normalized.ends_with(&format!("/{skip}"))
// `BrowserMetrics-spare.pma` and friends.
|| normalized.starts_with(&format!("{skip}-"))
})
}
fn is_skipped_file(name: &str) -> bool {
SKIP_FILES.contains(&name)
|| SKIP_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
|| name.starts_with("BrowserMetrics")
}
/// Copy the source's permission bits onto a file we produced ourselves.
///
/// `fs::copy` already preserves the mode, but `VACUUM INTO` lets SQLite create
/// the destination at its own default (0644). Cookies, Login Data and Web Data
/// are 0600 in both the source browser and Wayfern, and an import must not be
/// the step that widens them.
#[cfg(unix)]
fn mirror_mode(source: &Path, dest: &Path) {
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = fs::metadata(source) {
let mode = metadata.permissions().mode() & 0o777;
let _ = fs::set_permissions(dest, fs::Permissions::from_mode(mode));
}
}
#[cfg(not(unix))]
fn mirror_mode(_source: &Path, _dest: &Path) {}
/// Create a directory owner-only, matching what Chromium gives a profile.
fn create_private_dir(path: &Path) -> std::io::Result<()> {
fs::create_dir_all(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700));
}
Ok(())
}
/// Take a consistent snapshot of a SQLite database.
///
/// Returns `Ok(false)` when the file is not actually SQLite (an empty
/// placeholder, say), so the caller can fall back to a plain copy.
fn vacuum_into(source: &Path, dest: &Path) -> Result<bool, String> {
use rusqlite::{Connection, OpenFlags};
let conn = match Connection::open_with_flags(
source,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
) {
Ok(conn) => conn,
Err(e) => return Err(format!("open failed: {e}")),
};
// Confirm it really is a database before trusting VACUUM's error reporting.
if conn
.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
r.get::<_, i64>(0)
})
.is_err()
{
return Ok(false);
}
if dest.exists() {
fs::remove_file(dest).map_err(|e| format!("could not replace destination: {e}"))?;
}
// `VACUUM INTO` needs the path as a SQL string literal; single quotes are
// the only character that can break out of one.
let target = dest.to_string_lossy().replace('\'', "''");
conn
.execute_batch(&format!("VACUUM INTO '{target}'"))
.map_err(|e| format!("VACUUM INTO failed: {e}"))?;
mirror_mode(source, dest);
Ok(true)
}
/// Copy `source` (a Chromium profile directory) into `dest`, skipping caches
/// and snapshotting databases.
pub fn copy_profile_tree(source: &Path, dest: &Path) -> Result<CopyOutcome, String> {
let mut outcome = CopyOutcome {
bytes_copied: 0,
unreadable_stores: Vec::new(),
};
create_private_dir(dest).map_err(|e| format!("Failed to create {}: {e}", dest.display()))?;
copy_dir(source, dest, Path::new(""), &mut outcome)?;
Ok(outcome)
}
fn copy_dir(
source: &Path,
dest: &Path,
relative: &Path,
outcome: &mut CopyOutcome,
) -> Result<(), String> {
let entries =
fs::read_dir(source).map_err(|e| format!("Failed to read {}: {e}", source.display()))?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let child_relative = relative.join(name);
let source_path = entry.path();
let dest_path = dest.join(name);
// Symlinks are followed nowhere: Chromium writes them for the singleton
// lock, and a copied one would point at the source machine.
let metadata = match fs::symlink_metadata(&source_path) {
Ok(m) => m,
Err(_) => continue,
};
if metadata.file_type().is_symlink() {
continue;
}
if metadata.is_dir() {
if is_skipped_dir(&child_relative) {
continue;
}
create_private_dir(&dest_path)
.map_err(|e| format!("Failed to create {}: {e}", dest_path.display()))?;
copy_dir(&source_path, &dest_path, &child_relative, outcome)?;
continue;
}
if is_skipped_file(name) {
continue;
}
if SQLITE_FILES.contains(&name) {
match vacuum_into(&source_path, &dest_path) {
Ok(true) => {
outcome.bytes_copied += fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
continue;
}
Ok(false) => {
// Not a database after all; fall through to a byte copy.
}
Err(e) => {
// A store we cannot snapshot is a store we must not copy: a torn
// copy is deleted by Chromium on open, which looks identical to
// "the import silently lost my data".
log::warn!("Skipping unreadable store {}: {e}", source_path.display());
outcome.unreadable_stores.push(name.to_string());
continue;
}
}
}
match fs::copy(&source_path, &dest_path) {
Ok(bytes) => outcome.bytes_copied += bytes,
Err(e) => log::warn!("Failed to copy {}: {e}", source_path.display()),
}
}
Ok(())
}
/// Every `Default/`-level store that holds real user data, for reporting.
pub fn count_leveldb_origins(leveldb_dir: &Path) -> usize {
// Counting keys would mean linking a LevelDB implementation. The number of
// `.ldb`/`.log` segments is a stable proxy for "there is data here", which
// is all the report claims.
let Ok(entries) = fs::read_dir(leveldb_dir) else {
return 0;
};
entries
.flatten()
.filter(|e| {
e.file_name()
.to_str()
.is_some_and(|n| n.ends_with(".ldb") || n.ends_with(".log"))
})
.count()
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
use tempfile::TempDir;
fn touch(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
#[test]
fn caches_are_not_copied() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Cache").join("data_0"), &[0u8; 4096]);
touch(
&source.join("Code Cache").join("js").join("x"),
&[0u8; 4096],
);
touch(
&source.join("Service Worker").join("CacheStorage").join("y"),
&[0u8; 4096],
);
touch(
&source
.join("Service Worker")
.join("Database")
.join("CURRENT"),
b"MANIFEST-000001\n",
);
copy_profile_tree(&source, &dest).unwrap();
assert!(dest.join("Preferences").exists());
assert!(!dest.join("Cache").exists());
assert!(!dest.join("Code Cache").exists());
assert!(!dest.join("Service Worker").join("CacheStorage").exists());
assert!(
dest.join("Service Worker").join("Database").exists(),
"the Service Worker registry is real data and must survive"
);
}
#[test]
fn lock_and_journal_files_are_not_copied() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(
&source.join("Local Storage").join("leveldb").join("LOCK"),
b"",
);
touch(
&source.join("Local Storage").join("leveldb").join("CURRENT"),
b"MANIFEST-000001\n",
);
touch(&source.join("History-journal"), b"junk");
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest
.join("Local Storage")
.join("leveldb")
.join("LOCK")
.exists());
assert!(dest
.join("Local Storage")
.join("leveldb")
.join("CURRENT")
.exists());
assert!(!dest.join("History-journal").exists());
}
#[test]
fn sqlite_stores_are_snapshotted_and_stay_queryable() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("History");
let conn = Connection::open(&db).unwrap();
conn
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT); INSERT INTO urls(url) VALUES('https://example.com');")
.unwrap();
drop(conn);
copy_profile_tree(&source, &dest).unwrap();
let copied = Connection::open(dest.join("History")).unwrap();
let count: i64 = copied
.query_row("SELECT count(*) FROM urls", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn snapshot_captures_uncheckpointed_wal_content() {
// The whole reason for VACUUM INTO: a running browser leaves recent writes
// in the WAL, and a plain file copy loses them.
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("History");
let conn = Connection::open(&db).unwrap();
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
conn
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT);")
.unwrap();
conn
.execute("INSERT INTO urls(url) VALUES('https://in-wal.example')", [])
.unwrap();
// Deliberately do not checkpoint or close: this is the live-browser shape.
copy_profile_tree(&source, &dest).unwrap();
drop(conn);
let copied = Connection::open(dest.join("History")).unwrap();
let url: String = copied
.query_row("SELECT url FROM urls", [], |r| r.get(0))
.unwrap();
assert_eq!(url, "https://in-wal.example");
assert!(
!dest.join("History-wal").exists(),
"a stale -wal beside a vacuumed file corrupts it"
);
}
#[test]
fn symlinks_are_never_followed() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
let outside = dir.path().join("outside.txt");
touch(&outside, b"secret");
#[cfg(unix)]
std::os::unix::fs::symlink(&outside, source.join("SingletonLock")).unwrap();
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest.join("SingletonLock").exists());
}
#[test]
fn account_scoped_stores_are_dropped() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Login Data For Account"), b"x");
touch(&source.join("Sync Data").join("Nigori.bin"), b"x");
touch(
&source.join("Sync Data").join("LevelDB").join("CURRENT"),
b"x",
);
copy_profile_tree(&source, &dest).unwrap();
assert!(!dest.join("Login Data For Account").exists());
assert!(
!dest.join("Sync Data").join("Nigori.bin").exists(),
"the Nigori keyset is bound to a Google account"
);
assert!(
dest
.join("Sync Data")
.join("LevelDB")
.join("CURRENT")
.exists(),
"the rest of Sync Data is local state such as the reading list"
);
}
#[test]
#[cfg(unix)]
fn copied_databases_keep_the_browsers_private_permissions() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
fs::create_dir_all(&source).unwrap();
touch(&source.join("Preferences"), b"{}");
let db = source.join("Cookies");
let conn = rusqlite::Connection::open(&db).unwrap();
conn
.execute_batch("CREATE TABLE cookies(x INTEGER);")
.unwrap();
drop(conn);
fs::set_permissions(&db, fs::Permissions::from_mode(0o600)).unwrap();
copy_profile_tree(&source, &dest).unwrap();
// VACUUM INTO would otherwise create the snapshot at SQLite's default 0644.
let mode = fs::metadata(dest.join("Cookies"))
.unwrap()
.permissions()
.mode();
assert_eq!(
mode & 0o777,
0o600,
"an import must not widen a cookie store"
);
let dir_mode = fs::metadata(&dest).unwrap().permissions().mode();
assert_eq!(dir_mode & 0o777, 0o700);
}
#[test]
fn unreadable_store_is_reported_not_copied_corrupt() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
// A file that opens as SQLite but is structurally broken.
touch(
&source.join("Cookies"),
b"SQLite format 3\0garbage-not-a-db",
);
let outcome = copy_profile_tree(&source, &dest).unwrap();
assert!(
!dest.join("Cookies").exists() || outcome.unreadable_stores.is_empty(),
"a store is either snapshotted cleanly or skipped and reported"
);
}
#[test]
fn non_sqlite_file_with_a_store_name_still_copies() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("src");
let dest = dir.path().join("dst");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Top Sites"), b"");
copy_profile_tree(&source, &dest).unwrap();
assert!(dest.join("Top Sites").exists());
}
}
+392
View File
@@ -0,0 +1,392 @@
//! Recovering the *source* browser's os_crypt key.
//!
//! Every Chromium-family browser seals cookies, passwords and payment data with
//! a key held outside the profile: the macOS Keychain, a DPAPI blob in
//! `Local State`, or the Freedesktop secret service. Import has to open that
//! lock before it can re-seal anything with Wayfern's portable key
//! ([`super::os_crypt::TargetKey`]).
//!
//! Failure here is never fatal. A declined Keychain prompt or a locked keyring
//! degrades to "everything except the secrets came across", recorded as a
//! warning, because a partial profile is worth far more than a failed import.
#[cfg(target_os = "windows")]
use super::os_crypt::CryptoKey;
use super::os_crypt::SourceKeyring;
#[cfg(target_os = "macos")]
use super::os_crypt::MAC_ITERATIONS;
#[cfg(any(target_os = "macos", target_os = "linux"))]
use super::os_crypt::{derive_key, CryptoKey};
#[cfg(target_os = "linux")]
use super::os_crypt::{POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS};
use super::report::warning;
use std::path::Path;
/// Keychain / secret-service identities to try for a source family, most
/// specific first.
///
/// Trying several is safe and costs nothing: a lookup for a service that does
/// not exist fails without prompting, so at most one dialog appears — the one
/// for the item that is actually there. That is what lets a single `chromium`
/// family key cover both Google Chrome and vanilla Chromium, which share a
/// detection entry but not a Keychain item.
fn brand_candidates(family: &str, source_path: &Path) -> Vec<&'static str> {
let path = source_path.to_string_lossy();
let mut brands: Vec<&'static str> = match family {
"chrome-beta" => vec!["Chrome Beta", "Chrome"],
"chrome-dev" => vec!["Chrome Dev", "Chrome"],
"chrome-canary" => vec!["Chrome Canary", "Chrome"],
"brave" => vec!["Brave", "Brave Browser"],
"brave-beta" => vec!["Brave Beta", "Brave Browser", "Brave"],
"brave-nightly" => vec!["Brave Nightly", "Brave Browser", "Brave"],
"edge" => vec!["Microsoft Edge", "Chromium"],
"edge-beta" => vec!["Microsoft Edge Beta", "Microsoft Edge"],
"edge-dev" => vec!["Microsoft Edge Dev", "Microsoft Edge"],
"vivaldi" => vec!["Vivaldi", "Chromium"],
"opera" => vec!["Opera", "Chromium"],
"opera-gx" => vec!["Opera GX", "Opera", "Chromium"],
"arc" => vec!["Arc", "Chromium"],
"yandex" => vec!["Yandex", "Yandex Browser", "Chromium"],
// "chromium" covers both Google Chrome and upstream Chromium; the install
// path is the only thing that tells them apart.
_ => vec!["Chrome", "Chromium"],
};
if (family.is_empty() || family == "chromium")
&& path.contains("Chromium")
&& !path.contains("Google")
{
brands = vec!["Chromium", "Chrome"];
}
brands
}
/// Recover whatever key material the source browser used.
///
/// `source_user_data_dir` is the directory holding `Local State` (the parent of
/// the profile directory), which is where Windows keeps its wrapped key. It is
/// `None` when the user pointed at a bare profile folder with no parent we can
/// trust.
pub fn recover_source_keys(
family: &str,
source_path: &Path,
source_user_data_dir: Option<&Path>,
report: &mut super::report::ProfileImportReport,
) -> SourceKeyring {
let mut keyring = SourceKeyring::default();
#[cfg(target_os = "macos")]
{
let _ = source_user_data_dir;
for brand in brand_candidates(family, source_path) {
match macos_keychain_password(brand) {
Ok(Some(password)) => {
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(&password, MAC_ITERATIONS)));
log::info!("Recovered os_crypt password for '{brand} Safe Storage'");
break;
}
Ok(None) => continue,
Err(e) => {
log::warn!("Keychain lookup for '{brand} Safe Storage' failed: {e}");
break;
}
}
}
}
#[cfg(target_os = "windows")]
{
let _ = source_path;
if let Some(dir) = source_user_data_dir {
match windows_local_state_key(dir) {
Ok(Some(key)) => keyring.v10 = Some(CryptoKey::Aes256Gcm(key)),
Ok(None) => {}
Err(e) => log::warn!("DPAPI key recovery failed: {e}"),
}
if windows_has_app_bound_key(dir) {
// Recorded up front: the cookie store will be full of `v20` records
// and the user deserves to know why before they see the count.
report.warn(warning::APP_BOUND_ENCRYPTED);
}
}
}
#[cfg(target_os = "linux")]
{
let _ = source_user_data_dir;
// A profile can hold both tags at once, so populate both slots rather than
// choosing one. v10 is always available: it is a hardcoded password.
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(
POSIX_FALLBACK_PASSWORD,
POSIX_ITERATIONS,
)));
for brand in brand_candidates(family, source_path) {
match linux_secret_service_password(brand) {
Ok(Some(password)) => {
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(
&password,
POSIX_ITERATIONS,
)));
log::info!("Recovered os_crypt secret for '{brand} Safe Storage'");
break;
}
Ok(None) => continue,
Err(e) => {
log::warn!("Secret service lookup for '{brand} Safe Storage' failed: {e}");
break;
}
}
}
}
if keyring.is_empty() {
report.warn(warning::SECRETS_NOT_MIGRATED);
}
// Silence unused-parameter warnings on platforms that do not use every arg.
let _ = (family, source_path, source_user_data_dir);
keyring
}
/// How long to wait on a keyring before giving up.
///
/// Both backends can put a dialog in front of the user — macOS asks whether
/// Donut may read another app's Keychain item, and an unlocked-on-demand
/// keyring prompts on Linux. That is fine interactively, but an import driven
/// over REST or MCP would otherwise wedge forever with nobody at the screen.
/// Long enough for a person to notice and click; short enough that automation
/// recovers into "secrets not migrated", which is merely a partial import.
#[cfg(any(target_os = "macos", target_os = "linux"))]
const KEYRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
/// Run a keyring lookup on its own OS thread, bounded by [`KEYRING_TIMEOUT`].
///
/// Off-thread rather than inline for two reasons: import already runs inside
/// `spawn_blocking`, and zbus's blocking API drives a private tokio runtime, so
/// keeping it off a runtime-owned thread sidesteps any nested-runtime question;
/// and it turns a panic or a stuck IPC call into a recoverable warning instead
/// of a failed import.
#[cfg(any(target_os = "macos", target_os = "linux"))]
fn run_keyring_lookup<F>(what: &str, lookup: F) -> Result<Option<Vec<u8>>, String>
where
F: FnOnce() -> Result<Option<Vec<u8>>, String> + Send + std::panic::UnwindSafe + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result =
std::panic::catch_unwind(lookup).unwrap_or_else(|_| Err("lookup panicked".to_string()));
let _ = tx.send(result);
});
match rx.recv_timeout(KEYRING_TIMEOUT) {
Ok(result) => result,
Err(_) => Err(format!("{what} did not respond")),
}
}
#[cfg(target_os = "macos")]
fn macos_keychain_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
let brand = brand.to_string();
run_keyring_lookup("keychain", move || macos_keychain_lookup(&brand))
}
#[cfg(target_os = "macos")]
fn macos_keychain_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
use security_framework::passwords::get_generic_password;
let service = format!("{brand} Safe Storage");
match get_generic_password(&service, brand) {
Ok(password) => Ok(Some(password)),
Err(e) => {
// errSecItemNotFound: this brand simply is not installed. Anything else
// (notably errSecAuthFailed / errSecUserCanceled when the user declines
// the access dialog) is a real failure worth surfacing.
if e.code() == -25300 {
Ok(None)
} else {
Err(e.to_string())
}
}
}
}
#[cfg(target_os = "windows")]
fn read_local_state_os_crypt(dir: &Path) -> Option<serde_json::Value> {
let raw = std::fs::read_to_string(dir.join("Local State")).ok()?;
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
parsed.get("os_crypt").cloned()
}
#[cfg(target_os = "windows")]
fn windows_has_app_bound_key(dir: &Path) -> bool {
read_local_state_os_crypt(dir)
.and_then(|v| {
v.get("app_bound_encrypted_key")
.and_then(|k| k.as_str().map(str::to_string))
})
.is_some_and(|k| !k.is_empty())
}
#[cfg(target_os = "windows")]
fn windows_local_state_key(dir: &Path) -> Result<Option<[u8; 32]>, String> {
use base64::Engine;
let Some(os_crypt) = read_local_state_os_crypt(dir) else {
return Ok(None);
};
let Some(encoded) = os_crypt.get("encrypted_key").and_then(|k| k.as_str()) else {
return Ok(None);
};
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| format!("encrypted_key is not valid base64: {e}"))?;
// The blob is "DPAPI" || CryptProtectData(key).
const DPAPI_PREFIX: &[u8] = b"DPAPI";
if !decoded.starts_with(DPAPI_PREFIX) {
return Err("encrypted_key is missing the DPAPI header".to_string());
}
let unwrapped = dpapi_unprotect(&decoded[DPAPI_PREFIX.len()..])?;
let key: [u8; 32] = unwrapped
.as_slice()
.try_into()
.map_err(|_| format!("expected a 32-byte AES key, got {} bytes", unwrapped.len()))?;
Ok(Some(key))
}
#[cfg(target_os = "windows")]
fn dpapi_unprotect(ciphertext: &[u8]) -> Result<Vec<u8>, String> {
use windows::Win32::Foundation::LocalFree;
use windows::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB};
let mut input = CRYPT_INTEGER_BLOB {
cbData: ciphertext.len() as u32,
pbData: ciphertext.as_ptr() as *mut u8,
};
let mut output = CRYPT_INTEGER_BLOB::default();
// SAFETY: `input` points at a live slice for the duration of the call, and
// `output` is freed via LocalFree exactly once below, as the API requires.
unsafe {
CryptUnprotectData(&mut input, None, None, None, None, 0, &mut output)
.map_err(|e| format!("CryptUnprotectData failed: {e}"))?;
let plaintext = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec();
let _ = LocalFree(Some(windows::Win32::Foundation::HLOCAL(
output.pbData as *mut core::ffi::c_void,
)));
Ok(plaintext)
}
}
#[cfg(target_os = "linux")]
fn linux_secret_service_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
let brand = brand.to_string();
run_keyring_lookup("secret service", move || {
linux_secret_service_lookup(&brand)
})
}
#[cfg(target_os = "linux")]
fn linux_secret_service_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
use secret_service::blocking::SecretService;
use secret_service::EncryptionType;
use std::collections::HashMap;
let service =
SecretService::connect(EncryptionType::Dh).map_err(|e| format!("no secret service: {e}"))?;
let collection = service
.get_default_collection()
.map_err(|e| format!("no default collection: {e}"))?;
if collection.is_locked().unwrap_or(true) {
collection
.unlock()
.map_err(|e| format!("keyring is locked: {e}"))?;
}
// Match on the item's LABEL, not on its `application` attribute.
//
// `freedesktop_secret_key_provider.cc` stores two attributes —
// `application: kAppName` and `xdg:schema` — and sets the label to
// `kKeyName`, which is always "<Brand> Safe Storage". `kAppName` is a
// per-fork branding string ("chrome", "chromium", …) that we cannot derive
// from a display name: lowercasing "Microsoft Edge" gives "microsoft edge",
// which matches nothing, and the search would silently return zero items.
// The label is the one identifier that is the same across every fork and is
// exactly the string we already build for the macOS Keychain.
let label = format!("{brand} Safe Storage");
// The schema attribute narrows the scan to os_crypt secrets; it is shared by
// every Chromium fork, so it costs nothing in portability.
let mut attributes = HashMap::new();
attributes.insert("xdg:schema", "chrome_libsecret_os_crypt_password_v2");
let mut items = collection
.search_items(attributes)
.map_err(|e| format!("search failed: {e}"))?;
if items.is_empty() {
// Older Chromium releases used a v1 schema, and some forks omit it.
items = collection
.get_all_items()
.map_err(|e| format!("could not list items: {e}"))?;
}
for item in &items {
if item.get_label().is_ok_and(|found| found == label) {
return item
.get_secret()
.map(Some)
.map_err(|e| format!("could not read secret: {e}"));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn chromium_family_disambiguates_chrome_from_chromium_by_path() {
let chrome = PathBuf::from("/Users/x/Library/Application Support/Google/Chrome/Default");
assert_eq!(brand_candidates("chromium", &chrome)[0], "Chrome");
let chromium = PathBuf::from("/Users/x/Library/Application Support/Chromium/Default");
assert_eq!(brand_candidates("chromium", &chromium)[0], "Chromium");
}
#[test]
fn every_brand_falls_back_to_a_second_candidate() {
// A single candidate means one wrong guess loses the secrets entirely, so
// each family must offer a fallback identity.
for family in [
"chrome-beta",
"chrome-dev",
"chrome-canary",
"brave",
"edge",
"vivaldi",
"opera",
"opera-gx",
"arc",
"yandex",
"chromium",
] {
let candidates = brand_candidates(family, Path::new("/tmp/profile"));
assert!(
candidates.len() >= 2,
"{family} needs a fallback brand candidate"
);
}
}
#[test]
fn unknown_family_still_yields_candidates() {
let candidates = brand_candidates("something-new", Path::new("/tmp/profile"));
assert!(!candidates.is_empty());
}
}
+380
View File
@@ -0,0 +1,380 @@
//! Working out what the user pointed at, and where its files have to land.
//!
//! Two layout facts drive everything here:
//!
//! 1. Donut launches with `--user-data-dir` and no `--profile-directory`, so
//! Chromium reads `<user-data-dir>/Default/` (`chrome_constants.cc`
//! `kInitialProfile`). A source *profile* directory therefore has to be
//! copied one level down, not onto the root.
//! 2. Network state (`Cookies`, `TransportSecurity`, …) lives in
//! `Default/Network/` on Windows and in `Default/` everywhere else. That
//! split is not cosmetic: `kTriggerNetworkDataMigration` is enabled by
//! default only on Windows, and on the other platforms Chromium actively
//! redirects reads back to `Default/`. A profile exported from Windows is
//! invisible on macOS until its files are moved up, and vice versa.
use std::path::{Path, PathBuf};
/// Files Chromium keeps under `Default/Network/` on Windows and directly under
/// `Default/` on macOS and Linux.
pub const NETWORK_DATA_FILES: &[&str] = &[
"Cookies",
"Cookies-journal",
"Network Persistent State",
"Reporting and NEL",
"SCT Auditing Pending Reports",
"Trust Tokens",
"Trust Tokens-journal",
"TransportSecurity",
"Device Bound Sessions",
"Device Bound Sessions-journal",
];
/// What the user handed us.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SourceKind {
/// A profile directory (holds `Preferences`): `.../Chrome/Default`.
ProfileDir,
/// A user-data directory whose profile lives at its root — Opera's layout.
RootProfileUserDataDir,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SourceShape {
pub kind: SourceKind,
/// The directory holding `Preferences` — the content that becomes `Default/`.
pub profile_dir: PathBuf,
/// The directory holding `Local State`, when there is one. Windows keeps the
/// DPAPI-wrapped os_crypt key there, so losing it loses every secret.
pub user_data_dir: Option<PathBuf>,
}
/// Why a directory cannot be imported.
#[derive(Debug, PartialEq, Eq)]
pub enum RejectReason {
/// Recognisably a Gecko profile. Worth naming explicitly: silently returning
/// "nothing found" for a Firefox folder is what made import feel broken.
Firefox,
/// Not a browser profile we recognise at all.
NotChromium,
}
/// Markers that identify a real Chromium profile directory. `Preferences` is
/// the usual one, but a profile whose prefs were wiped still has data worth
/// carrying, so any of these counts.
const CHROMIUM_PROFILE_MARKERS: &[&str] = &[
"Preferences",
"Secure Preferences",
"History",
"Cookies",
"Bookmarks",
"Web Data",
"Login Data",
];
fn looks_like_chromium_profile(dir: &Path) -> bool {
CHROMIUM_PROFILE_MARKERS
.iter()
.any(|marker| dir.join(marker).exists())
// Windows-layout profiles keep Cookies one level down.
|| dir.join("Network").join("Cookies").exists()
}
fn looks_like_firefox_profile(dir: &Path) -> bool {
// Any one of these alone can appear elsewhere; together they are conclusive.
let markers = ["prefs.js", "places.sqlite", "cookies.sqlite", "key4.db"];
markers.iter().filter(|m| dir.join(m).exists()).count() >= 2
}
/// Classify an import source, or explain why it cannot be one.
pub fn classify(source: &Path) -> Result<SourceShape, RejectReason> {
if looks_like_firefox_profile(source) {
return Err(RejectReason::Firefox);
}
if !looks_like_chromium_profile(source) {
return Err(RejectReason::NotChromium);
}
// A directory that holds both profile markers and `Local State` is Opera's
// root-profile layout: the user-data dir and the profile are the same place.
let kind = if source.join("Local State").exists() {
SourceKind::RootProfileUserDataDir
} else {
SourceKind::ProfileDir
};
let user_data_dir = match kind {
SourceKind::RootProfileUserDataDir => Some(source.to_path_buf()),
// For `.../Chrome/Default`, `Local State` is in `.../Chrome`. Only accept
// the parent if it really holds one, so a profile copied to a random
// folder does not make us read a stranger's `Local State`.
SourceKind::ProfileDir => source.parent().and_then(|parent| {
if parent.join("Local State").exists() {
return Some(parent.to_path_buf());
}
// Opera keeps its extra profiles at `<user-data-dir>/_side_profiles/<id>`
// but still launches them against the same user-data dir, so the
// DPAPI-wrapped os_crypt key sits one further level up. Without this,
// every Opera side profile imports on Windows with no secrets at all.
if parent.file_name() == Some(std::ffi::OsStr::new("_side_profiles")) {
return parent
.parent()
.filter(|root| root.join("Local State").exists())
.map(Path::to_path_buf);
}
None
}),
};
Ok(SourceShape {
kind,
profile_dir: source.to_path_buf(),
user_data_dir,
})
}
/// Move network data into the position the *host* Chromium build reads from.
///
/// Host, not source: the files were written by whatever browser produced them,
/// but they will be read by Wayfern running here. Getting this backwards is a
/// silent, total cookie loss on any cross-platform import.
pub fn normalize_network_dir(default_dir: &Path) -> std::io::Result<()> {
let network_dir = default_dir.join("Network");
let (from, to) = if cfg!(target_os = "windows") {
(default_dir.to_path_buf(), network_dir.clone())
} else {
(network_dir.clone(), default_dir.to_path_buf())
};
if !from.exists() {
return Ok(());
}
for name in NETWORK_DATA_FILES {
let src = from.join(name);
if !src.is_file() {
continue;
}
std::fs::create_dir_all(&to)?;
let dest = to.join(name);
if dest.exists() {
// Both positions hold the file. The one in the source position is the
// stale duplicate: on Windows, Chromium's migration would copy it over
// the newer file ("overwrite the new file with the old file even if it
// exists already", network_sandbox.cc), so it has to go.
std::fs::remove_file(&src)?;
continue;
}
std::fs::rename(&src, &dest).or_else(|_| {
// Rename across devices can fail even within one tree on some setups.
std::fs::copy(&src, &dest).and_then(|_| std::fs::remove_file(&src))?;
Ok::<(), std::io::Error>(())
})?;
}
if !cfg!(target_os = "windows") {
// Chromium's migration checkpoint, and the reason an otherwise-correct
// move is not enough. `network_sandbox.cc:478` treats the presence of
// `NetworkDataMigrated` as proof the migration already ran, keeps the (now
// empty) `Network/` as the data directory, and then `CleanUpOldData` at
// `:536-540` DELETES the files we just moved up into `Default/`. A profile
// exported from Windows would lose every cookie on first launch.
let _ = std::fs::remove_file(network_dir.join("NetworkDataMigrated"));
// Leave no empty `Network/` behind: harmless, but it makes a profile look
// like it still holds network state.
if network_dir.is_dir() && std::fs::read_dir(&network_dir)?.next().is_none() {
let _ = std::fs::remove_dir(&network_dir);
}
}
Ok(())
}
/// Where the cookie store ends up for the host platform.
pub fn host_cookie_path(default_dir: &Path) -> PathBuf {
if cfg!(target_os = "windows") {
default_dir.join("Network").join("Cookies")
} else {
default_dir.join("Cookies")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch(path: &Path) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, b"x").unwrap();
}
#[test]
fn plain_profile_dir_is_classified_without_a_user_data_dir() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("Default");
touch(&profile.join("Preferences"));
let shape = classify(&profile).expect("should classify");
assert_eq!(shape.kind, SourceKind::ProfileDir);
assert_eq!(shape.user_data_dir, None);
}
#[test]
fn profile_dir_finds_local_state_in_its_parent() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("Default");
touch(&profile.join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(&profile).expect("should classify");
// Windows keeps the wrapped os_crypt key here; missing it means no secrets.
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
}
#[test]
fn opera_root_layout_is_its_own_user_data_dir() {
let dir = TempDir::new().unwrap();
touch(&dir.path().join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(dir.path()).expect("should classify");
assert_eq!(shape.kind, SourceKind::RootProfileUserDataDir);
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
}
#[test]
fn firefox_profile_is_rejected_by_name() {
let dir = TempDir::new().unwrap();
touch(&dir.path().join("prefs.js"));
touch(&dir.path().join("places.sqlite"));
assert_eq!(classify(dir.path()), Err(RejectReason::Firefox));
}
#[test]
fn empty_directory_is_rejected() {
let dir = TempDir::new().unwrap();
assert_eq!(classify(dir.path()), Err(RejectReason::NotChromium));
}
#[test]
fn windows_layout_profile_is_recognised_without_root_markers() {
// A profile whose only surviving data is Windows-layout cookies.
let dir = TempDir::new().unwrap();
touch(&dir.path().join("Network").join("Cookies"));
assert!(classify(dir.path()).is_ok());
}
#[test]
fn opera_side_profile_finds_local_state_two_levels_up() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("_side_profiles").join("gaming");
touch(&profile.join("Preferences"));
touch(&dir.path().join("Local State"));
let shape = classify(&profile).expect("should classify");
assert_eq!(
shape.user_data_dir.as_deref(),
Some(dir.path()),
"Windows keeps the os_crypt key in the root Local State, not beside the profile"
);
}
#[test]
fn a_profile_in_an_unrelated_folder_does_not_adopt_a_strangers_local_state() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("_side_profiles").join("gaming");
touch(&profile.join("Preferences"));
// No Local State anywhere above it.
let shape = classify(&profile).expect("should classify");
assert_eq!(shape.user_data_dir, None);
}
#[test]
fn migration_checkpoint_is_removed_so_chromium_does_not_delete_the_moved_files() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Network").join("Cookies"));
touch(&default_dir.join("Network").join("NetworkDataMigrated"));
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
if !cfg!(target_os = "windows") {
assert!(
!default_dir
.join("Network")
.join("NetworkDataMigrated")
.exists(),
"the checkpoint makes Chromium delete the files we just moved up"
);
assert!(!default_dir.join("Network").exists());
}
}
#[test]
fn network_files_are_moved_into_the_host_position() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
// Seed the file in the position the host does NOT read from.
if cfg!(target_os = "windows") {
touch(&default_dir.join("Cookies"));
} else {
touch(&default_dir.join("Network").join("Cookies"));
}
normalize_network_dir(&default_dir).unwrap();
assert!(
host_cookie_path(&default_dir).is_file(),
"cookies must end up where this platform's Chromium reads them"
);
}
#[test]
fn stale_duplicate_in_the_source_position_is_removed() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Cookies"));
touch(&default_dir.join("Network").join("Cookies"));
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
let stale = if cfg!(target_os = "windows") {
default_dir.join("Cookies")
} else {
default_dir.join("Network").join("Cookies")
};
assert!(
!stale.exists(),
"the duplicate would be copied over the live file by Chromium's migration"
);
}
#[test]
fn normalize_is_idempotent() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
touch(&default_dir.join("Network").join("Cookies"));
normalize_network_dir(&default_dir).unwrap();
normalize_network_dir(&default_dir).unwrap();
assert!(host_cookie_path(&default_dir).is_file());
}
#[test]
fn normalize_on_a_profile_with_no_network_data_is_a_no_op() {
let dir = TempDir::new().unwrap();
let default_dir = dir.path().join("Default");
std::fs::create_dir_all(&default_dir).unwrap();
normalize_network_dir(&default_dir).unwrap();
assert!(!host_cookie_path(&default_dir).exists());
}
}
+372
View File
@@ -0,0 +1,372 @@
//! Turning someone else's browser profile into one Wayfern will actually load.
//!
//! The old importer copied a source profile directory verbatim onto the new
//! profile's `--user-data-dir`. Chromium reads `<user-data-dir>/Default/`, so
//! every imported file sat one level above where the browser looked and the
//! profile came up empty — and even in the right place the secrets would not
//! have opened, because they are sealed with a key held in the source
//! machine's Keychain / DPAPI / secret service that Wayfern never consults.
//!
//! This module does the whole job: classify the source, recover its key, copy
//! with consistent database snapshots, put the files where Chromium reads them,
//! re-seal every secret with Wayfern's portable key, and report exactly what
//! came across.
pub mod copy;
pub mod keyring;
pub mod layout;
pub mod os_crypt;
pub mod report;
pub mod rewrite;
use layout::RejectReason;
use report::{warning, ProfileImportReport};
use std::path::Path;
/// The profile subdirectory Chromium reads when no `--profile-directory` is
/// passed (`chrome_constants.cc` `kInitialProfile`). Donut never passes one.
pub const INITIAL_PROFILE_DIR: &str = "Default";
/// Import `source` into `dest_user_data_dir`, which becomes the new profile's
/// `--user-data-dir`.
///
/// Never fails because part of the data could not be carried: partial results
/// plus an honest report beat an all-or-nothing import that leaves the user
/// with nothing and no explanation. It fails only when the source is not
/// importable at all, or when the target key cannot be established — without
/// that key, anything written would be unreadable forever.
pub fn import_into(
source: &Path,
dest_user_data_dir: &Path,
source_family: &str,
allow_running: bool,
) -> Result<ProfileImportReport, String> {
let shape = layout::classify(source).map_err(|reason| match reason {
RejectReason::Firefox => serde_json::json!({
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
"params": { "family": "Firefox" }
})
.to_string(),
RejectReason::NotChromium => serde_json::json!({
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
"params": { "family": "" }
})
.to_string(),
})?;
let mut report = ProfileImportReport::default();
if let Some(running) = running_source_browser(&shape) {
if !allow_running {
return Err(
serde_json::json!({
"code": "IMPORT_SOURCE_BROWSER_RUNNING",
"params": { "browser": running }
})
.to_string(),
);
}
// Databases are snapshotted transactionally, but LevelDB site data is
// copied as files and can be mid-write.
report.warn(warning::SOURCE_BROWSER_RUNNING);
}
// Mint the target key first. Everything after this point is written to be
// readable with it, and a profile whose key could not be persisted would
// lose every secret the first time the browser exits.
let target = os_crypt::TargetKey::ensure(dest_user_data_dir)?;
// Recover the source key before the copy: on macOS this may prompt, and
// asking before a multi-GB copy respects the user's time.
let source_keys = keyring::recover_source_keys(
source_family,
&shape.profile_dir,
shape.user_data_dir.as_deref(),
&mut report,
);
let default_dir = dest_user_data_dir.join(INITIAL_PROFILE_DIR);
let outcome = copy::copy_profile_tree(&shape.profile_dir, &default_dir)?;
report.bytes_copied = outcome.bytes_copied;
if !outcome.unreadable_stores.is_empty() {
report.warn(warning::STORE_UNREADABLE);
}
layout::normalize_network_dir(&default_dir)
.map_err(|e| format!("Failed to place network data: {e}"))?;
rewrite::finalize_profile(&default_dir, &source_keys, &target, &mut report);
Ok(report)
}
/// Is the browser that owns this profile currently running?
///
/// Matched on the profile path in the process command line rather than on the
/// executable name: the user may well have Chrome open on a *different*
/// profile, which is no reason to block the import.
fn running_source_browser(shape: &layout::SourceShape) -> Option<String> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let needle = shape
.user_data_dir
.as_deref()
.unwrap_or(&shape.profile_dir)
.to_string_lossy()
.to_string();
if needle.is_empty() {
return None;
}
for process in system.processes().values() {
let name = process.name().to_string_lossy().to_lowercase();
let looks_like_a_browser = name.contains("chrome")
|| name.contains("chromium")
|| name.contains("brave")
|| name.contains("edge")
|| name.contains("vivaldi")
|| name.contains("opera")
|| name.contains("arc")
|| name.contains("yandex");
if !looks_like_a_browser {
continue;
}
// Donut's own browser is Wayfern; never report it as the source.
if name.contains("wayfern") {
continue;
}
if process
.cmd()
.iter()
.any(|arg| arg.to_string_lossy().contains(&needle))
{
return Some(process.name().to_string_lossy().to_string());
}
}
None
}
/// Move a profile that an earlier build imported into the broken root layout
/// down into `Default/`, where the browser reads it.
///
/// Without this, everything those users imported stays stranded: their real
/// data sits at `profile/Cookies` while Wayfern reads and writes
/// `profile/Default/Cookies`. Their secrets remain unreadable — the source key
/// was never captured and cannot be recovered after the fact — but history,
/// bookmarks, extensions and site data become visible again.
///
/// Returns `Ok(true)` when a repair was performed.
pub fn repair_legacy_layout(user_data_dir: &Path) -> Result<bool, String> {
let default_dir = user_data_dir.join(INITIAL_PROFILE_DIR);
// The broken shape is exactly: profile markers at the root, and no `Default/`
// for the browser to have used instead.
let has_root_profile = user_data_dir.join("Preferences").exists()
|| user_data_dir.join("History").exists()
|| user_data_dir.join("Cookies").exists();
if !has_root_profile || default_dir.exists() {
return Ok(false);
}
// Root-level files that belong to the user-data dir, not to the profile.
const ROOT_LEVEL: &[&str] = &[
"Local State",
"os_crypt_key",
"First Run",
"Last Version",
"Variations",
"ChromeFeatureState",
"RunningChromeVersion",
"SingletonLock",
"SingletonCookie",
"SingletonSocket",
"user.js",
"metadata.json",
".donut-sync",
];
let staging = user_data_dir.join(".donut-import-repair");
if staging.exists() {
std::fs::remove_dir_all(&staging).map_err(|e| format!("Failed to clear staging: {e}"))?;
}
std::fs::create_dir_all(&staging).map_err(|e| format!("Failed to create staging: {e}"))?;
let entries =
std::fs::read_dir(user_data_dir).map_err(|e| format!("Failed to read profile: {e}"))?;
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if ROOT_LEVEL.contains(&name_str) || name_str == ".donut-import-repair" {
continue;
}
std::fs::rename(entry.path(), staging.join(name_str))
.map_err(|e| format!("Failed to relocate {name_str}: {e}"))?;
}
std::fs::rename(&staging, &default_dir)
.map_err(|e| format!("Failed to install {INITIAL_PROFILE_DIR}: {e}"))?;
// Now that the files are in the right place, put the network data where this
// platform reads it too.
let _ = layout::normalize_network_dir(&default_dir);
// And make sure the profile has a key, so the browser does not mint one
// mid-session and lose whatever it writes.
let _ = os_crypt::TargetKey::ensure(user_data_dir);
log::info!(
"Repaired legacy import layout at {} (moved profile content into {INITIAL_PROFILE_DIR}/)",
user_data_dir.display()
);
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, contents).unwrap();
}
#[test]
fn import_places_everything_under_default() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("Chrome").join("Default");
let dest = dir.path().join("profile");
touch(&source.join("Preferences"), b"{}");
touch(&source.join("Bookmarks"), b"{\"roots\":{}}");
let report = import_into(&source, &dest, "chromium", true).expect("import");
assert!(
dest.join("Default").join("Preferences").exists(),
"Chromium reads Default/, not the user-data-dir root"
);
assert!(
!dest.join("Preferences").exists(),
"nothing profile-scoped belongs at the root"
);
assert!(dest.join(os_crypt::KEY_FILE_NAME).exists());
assert!(report.bytes_copied > 0);
}
#[test]
fn import_rejects_a_firefox_profile_by_name() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("xyz.default-release");
let dest = dir.path().join("profile");
touch(&source.join("prefs.js"), b"");
touch(&source.join("places.sqlite"), b"");
let err = import_into(&source, &dest, "firefox", true).expect_err("must reject");
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
assert!(
err.contains("Firefox"),
"the user needs to be told why, not just that it failed"
);
}
#[test]
fn import_rejects_an_arbitrary_folder() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("holiday-photos");
let dest = dir.path().join("profile");
touch(&source.join("IMG_0001.jpg"), b"not a profile");
let err = import_into(&source, &dest, "chromium", true).expect_err("must reject");
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
}
#[test]
fn import_is_rerunnable_over_the_same_destination() {
let dir = TempDir::new().unwrap();
let source = dir.path().join("Default");
let dest = dir.path().join("profile");
touch(&source.join("Preferences"), b"{}");
import_into(&source, &dest, "chromium", true).expect("first");
let key = std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap();
import_into(&source, &dest, "chromium", true).expect("second");
assert_eq!(
std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap(),
key,
"re-running must not orphan what the first run encrypted"
);
}
#[test]
fn legacy_layout_is_repaired_into_default() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
// Exactly what the old importer produced.
touch(&profile.join("Preferences"), b"{}");
touch(&profile.join("History"), b"");
touch(
&profile
.join("Local Storage")
.join("leveldb")
.join("CURRENT"),
b"",
);
touch(&profile.join("Local State"), b"{}");
assert!(repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
assert!(profile.join("Default").join("History").exists());
assert!(profile
.join("Default")
.join("Local Storage")
.join("leveldb")
.join("CURRENT")
.exists());
assert!(
profile.join("Local State").exists(),
"Local State belongs to the user-data dir, not the profile"
);
assert!(profile.join(os_crypt::KEY_FILE_NAME).exists());
assert!(!profile.join(".donut-import-repair").exists());
}
#[test]
fn repair_leaves_a_healthy_profile_alone() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
touch(&profile.join("Default").join("Preferences"), b"{}");
touch(&profile.join("Local State"), b"{}");
assert!(!repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
assert!(!profile.join("Default").join("Default").exists());
}
#[test]
fn repair_is_a_no_op_on_an_empty_profile() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
std::fs::create_dir_all(&profile).unwrap();
assert!(!repair_legacy_layout(&profile).unwrap());
}
#[test]
fn repair_is_idempotent() {
let dir = TempDir::new().unwrap();
let profile = dir.path().join("profile");
touch(&profile.join("Preferences"), b"{}");
assert!(repair_legacy_layout(&profile).unwrap());
assert!(!repair_legacy_layout(&profile).unwrap());
assert!(profile.join("Default").join("Preferences").exists());
}
}
+560
View File
@@ -0,0 +1,560 @@
//! Key material for profile import.
//!
//! Wayfern deliberately does not use the OS keyring. Every `os_crypt_async`
//! key provider is patched to read (or mint) `<user-data-dir>/os_crypt_key`
//! instead, so a profile directory is self-contained and portable. See
//! `wayfern/patches/extra/fingerprint/components-os_crypt-async-browser-*`.
//!
//! That portability is exactly why an imported Chrome profile carries nothing:
//! its secrets are sealed with a key held in the macOS Keychain / Windows DPAPI
//! / the Freedesktop secret service, and Wayfern never looks there. Import has
//! to open the source's lock and re-seal everything with Wayfern's.
//!
//! The on-disk format is per-platform and NOT interchangeable, matching the
//! provider that owns each tag in the patched Chromium 151 tree:
//!
//! | Host | `os_crypt_key` | Derivation | Cipher | Tag |
//! |---------|---------------------|-------------------------------------|--------------|-------|
//! | macOS | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1003) | AES-128-CBC | `v10` |
//! | Linux | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1) | AES-128-CBC | `v11` |
//! | Windows | 32 raw bytes | none, the bytes are the key | AES-256-GCM | `v10` |
//!
//! Linux must write `v11`, not `v10`: `PosixKeyProvider` owns `v10` with the
//! hardcoded "peanuts" password and `Encryptor::DecryptData` dispatches on the
//! tag prefix, so a `v10` record on Linux would be decrypted with the wrong key
//! forever.
use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use base64::Engine;
use rand::RngExt;
use ring::pbkdf2;
use std::num::NonZeroU32;
use std::path::Path;
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
/// Chromium's fixed PBKDF2 salt for every CBC-based os_crypt provider.
pub const SALT: &[u8] = b"saltysalt";
/// Chromium's fixed CBC IV: sixteen spaces.
pub const CBC_IV: [u8; 16] = [b' '; 16];
/// AES-256-GCM nonce length, prepended to the ciphertext by `Encryptor::Key::Encrypt`.
const GCM_NONCE_LEN: usize = 12;
/// The `os_crypt_key` name, at the root of the user-data dir.
pub const KEY_FILE_NAME: &str = "os_crypt_key";
/// `PBKDF2-HMAC-SHA1(password = "", salt = "saltysalt", iterations = 1)`.
///
/// Chromium retries every failed AES-128-CBC decrypt with this key
/// (`encryptor.cc`, crbug.com/40055416) because profiles created while the
/// keyring was unavailable were sealed with an empty password. Import has to do
/// the same or those records look corrupt.
pub const EMPTY_PASSWORD_KEY: [u8; 16] = [
0xd0, 0xd0, 0xec, 0x9c, 0x7d, 0x77, 0xd4, 0x3a, 0xc5, 0x41, 0x87, 0xfa, 0x48, 0x18, 0xd1, 0x7f,
];
/// The password Chromium's `PosixKeyProvider` uses when no secret service is
/// available (`--password-store=basic`). Records sealed with it carry `v10`.
// Read on Linux and by the known-answer tests; unreferenced on other hosts.
#[allow(dead_code)]
pub const POSIX_FALLBACK_PASSWORD: &[u8] = b"peanuts";
/// PBKDF2 iteration counts, per the provider that owns each platform.
// Each host only ever derives with its own count, but both are needed to read
// a profile produced on the other one.
#[allow(dead_code)]
pub const MAC_ITERATIONS: u32 = 1003;
#[allow(dead_code)]
pub const POSIX_ITERATIONS: u32 = 1;
/// Derive a 16-byte AES-128 key the way every CBC os_crypt provider does.
///
/// `password` is the raw bytes, never trimmed: Chromium passes the exact
/// `ReadFileToString` result to the KDF, so normalising here would silently
/// produce a different key and every decrypt would fail.
pub fn derive_key(password: &[u8], iterations: u32) -> [u8; 16] {
let mut key = [0u8; 16];
// ring rather than the `pbkdf2` crate: sha1 0.11 (digest 0.11) and
// pbkdf2 0.12 (digest 0.10) cannot coexist. ring is self-contained.
pbkdf2::derive(
pbkdf2::PBKDF2_HMAC_SHA1,
NonZeroU32::new(iterations).expect("iterations must be non-zero"),
SALT,
password,
&mut key,
);
key
}
/// One os_crypt cipher, keyed. Which variant applies is decided by the tag the
/// record carries, never by the host platform.
#[derive(Clone)]
pub enum CryptoKey {
Aes128Cbc([u8; 16]),
// Only Windows keys with GCM, but the variant has to exist everywhere so the
// tag dispatch in `SourceKeyring` stays platform-independent.
#[allow(dead_code)]
Aes256Gcm([u8; 32]),
}
impl CryptoKey {
/// Decrypt a *tagless* ciphertext (the caller has already stripped the
/// 3-byte version prefix).
pub fn decrypt(&self, ciphertext: &[u8]) -> Option<Vec<u8>> {
match self {
Self::Aes128Cbc(key) => {
if ciphertext.is_empty() {
return Some(Vec::new());
}
let mut buf = ciphertext.to_vec();
Aes128CbcDec::new(key.into(), &CBC_IV.into())
.decrypt_padded::<Pkcs7>(&mut buf)
.ok()
.map(<[u8]>::to_vec)
}
Self::Aes256Gcm(key) => {
if ciphertext.len() < GCM_NONCE_LEN {
return None;
}
let (nonce, body) = ciphertext.split_at(GCM_NONCE_LEN);
let nonce: [u8; GCM_NONCE_LEN] = nonce.try_into().ok()?;
Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
.decrypt(
&Nonce::from(nonce),
Payload {
msg: body,
aad: &[],
},
)
.ok()
}
}
}
/// Encrypt to a *tagless* ciphertext. The caller prepends the tag.
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
match self {
Self::Aes128Cbc(key) => {
let mut buf = vec![0u8; plaintext.len() + 16];
buf[..plaintext.len()].copy_from_slice(plaintext);
Aes128CbcEnc::new(key.into(), &CBC_IV.into())
.encrypt_padded::<Pkcs7>(&mut buf, plaintext.len())
.ok()
.map(<[u8]>::to_vec)
}
Self::Aes256Gcm(key) => {
let nonce: [u8; GCM_NONCE_LEN] = rand::rng().random();
let sealed = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
.encrypt(
&Nonce::from(nonce),
Payload {
msg: plaintext,
aad: &[],
},
)
.ok()?;
// The nonce goes at the front, matching `Encryptor::Key::Encrypt`.
let mut out = Vec::with_capacity(GCM_NONCE_LEN + sealed.len());
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Some(out)
}
}
}
}
/// Wayfern's key for the profile being created.
pub struct TargetKey {
key: CryptoKey,
tag: &'static [u8; 3],
}
impl TargetKey {
/// The tag the host platform's key provider claims.
pub const fn host_tag() -> &'static [u8; 3] {
#[cfg(target_os = "linux")]
{
b"v11"
}
#[cfg(not(target_os = "linux"))]
{
b"v10"
}
}
/// Build the key from the raw `os_crypt_key` file contents.
///
/// Returns `None` when the contents cannot key the host cipher — on Windows
/// that means anything other than exactly 32 bytes, which is what
/// `DPAPIKeyProvider` requires before it will adopt a portable key.
fn from_file_contents(contents: &[u8]) -> Option<Self> {
if contents.is_empty() {
return None;
}
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().ok()?;
Some(Self {
key: CryptoKey::Aes256Gcm(bytes),
tag: Self::host_tag(),
})
}
#[cfg(target_os = "macos")]
{
Some(Self {
key: CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)),
tag: Self::host_tag(),
})
}
#[cfg(target_os = "linux")]
{
Some(Self {
key: CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)),
tag: Self::host_tag(),
})
}
}
/// Fresh key material in the host platform's `os_crypt_key` format.
fn generate_file_contents() -> Vec<u8> {
#[cfg(target_os = "windows")]
{
// Windows stores the AES-256 key itself, so it must be 32 bytes.
let key: [u8; 32] = rand::rng().random();
key.to_vec()
}
#[cfg(not(target_os = "windows"))]
{
// mac/Linux store a *password* that is fed to PBKDF2. Wayfern mints
// `base64(16 random bytes)`; match it so the file is indistinguishable
// from one the browser wrote itself.
let raw: [u8; 16] = rand::rng().random();
base64::engine::general_purpose::STANDARD
.encode(raw)
.into_bytes()
}
}
/// Read the existing `os_crypt_key`, or mint and persist one.
///
/// Writing eagerly at import time — rather than letting the first launch do
/// it — is deliberate. The mac and Linux patches have no `else` branch when
/// the write fails, so the browser would run on an in-memory key that dies
/// with the process and orphans everything it wrote. Failing here instead
/// turns that silent data loss into a visible import error.
pub fn ensure(user_data_dir: &Path) -> Result<Self, String> {
let key_file = user_data_dir.join(KEY_FILE_NAME);
if let Ok(existing) = std::fs::read(&key_file) {
if let Some(key) = Self::from_file_contents(&existing) {
return Ok(key);
}
// Present but unusable (a Windows-format key on macOS, say, or a
// truncated write). Replacing it is safe only because import always
// re-encrypts into whatever key we end up with.
log::warn!(
"Replacing unusable {KEY_FILE_NAME} ({} bytes) at {}",
existing.len(),
key_file.display()
);
}
std::fs::create_dir_all(user_data_dir)
.map_err(|e| format!("Failed to create profile directory: {e}"))?;
let contents = Self::generate_file_contents();
std::fs::write(&key_file, &contents)
.map_err(|e| format!("Failed to write os_crypt_key: {e}"))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600));
}
// Read back rather than trust the write: a key that did not land is the
// one failure mode that silently destroys every secret we are about to
// write with it.
let written =
std::fs::read(&key_file).map_err(|e| format!("Failed to verify os_crypt_key: {e}"))?;
if written != contents {
return Err("os_crypt_key verification failed after write".to_string());
}
Self::from_file_contents(&contents).ok_or_else(|| "Failed to derive os_crypt_key".to_string())
}
/// Seal a value the way Wayfern will expect to find it: `tag || ciphertext`.
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
let body = self.key.encrypt(plaintext)?;
let mut out = Vec::with_capacity(3 + body.len());
out.extend_from_slice(self.tag);
out.extend_from_slice(&body);
Some(out)
}
}
/// What a decrypt attempt produced.
pub enum Decrypted {
/// Recovered plaintext.
Value(Vec<u8>),
/// Already plaintext — no recognised version tag.
NotEncrypted,
/// Correctly identified but not openable: no key for the tag (Windows
/// App-Bound `v20`), or every candidate key failed.
Unrecoverable,
}
/// The source browser's keys, indexed by the tag the records carry.
///
/// Indexing by tag rather than by platform is not pedantry: a single Linux
/// profile can legitimately hold both `v10` (peanuts) and `v11` (keyring)
/// records, because the available secret service changes between sessions.
#[derive(Default)]
pub struct SourceKeyring {
pub v10: Option<CryptoKey>,
pub v11: Option<CryptoKey>,
/// Seen at least one `v20` (Windows App-Bound) record, which no third party
/// can open. Tracked so the import report can say so explicitly.
pub saw_app_bound: std::cell::Cell<bool>,
}
impl SourceKeyring {
pub fn is_empty(&self) -> bool {
self.v10.is_none() && self.v11.is_none()
}
/// Open one stored value, dispatching on its version tag exactly as
/// `Encryptor::DecryptData` does.
pub fn decrypt(&self, stored: &[u8]) -> Decrypted {
if stored.len() < 3 {
return if stored.is_empty() {
Decrypted::Value(Vec::new())
} else {
Decrypted::NotEncrypted
};
}
let (tag, body) = stored.split_at(3);
let key = match tag {
b"v10" => self.v10.as_ref(),
b"v11" => self.v11.as_ref(),
b"v20" => {
// App-Bound Encryption. The key is wrapped by the SYSTEM-level Chrome
// Elevation Service, which validates the calling binary. There is no
// legitimate way for us to unwrap it.
self.saw_app_bound.set(true);
return Decrypted::Unrecoverable;
}
_ => return Decrypted::NotEncrypted,
};
let Some(key) = key else {
return Decrypted::Unrecoverable;
};
if let Some(plaintext) = key.decrypt(body) {
return Decrypted::Value(plaintext);
}
// Chromium's own fallback for CBC records sealed with an empty password.
if matches!(key, CryptoKey::Aes128Cbc(_)) {
if let Some(plaintext) = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY).decrypt(body) {
return Decrypted::Value(plaintext);
}
}
Decrypted::Unrecoverable
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn empty_password_key_matches_chromium_constant() {
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
}
#[test]
fn peanuts_key_matches_known_vector() {
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
// breaks every Linux `--password-store=basic` import.
assert_eq!(
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
[
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
0x78
]
);
}
#[test]
fn cbc_round_trip() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"session-token").expect("encrypt");
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
}
#[test]
fn cbc_round_trip_empty_plaintext() {
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
let sealed = key.encrypt(b"").expect("encrypt");
// PKCS7 always emits a full padding block, so this must not be empty.
assert_eq!(sealed.len(), 16);
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
}
#[test]
fn gcm_round_trip_with_fresh_nonce_each_time() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let a = key.encrypt(b"session-token").expect("encrypt");
let b = key.encrypt(b"session-token").expect("encrypt");
assert_ne!(a, b, "nonce must be random per call");
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
}
#[test]
fn gcm_rejects_tampered_ciphertext() {
let key = CryptoKey::Aes256Gcm([7u8; 32]);
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
let last = sealed.len() - 1;
sealed[last] ^= 0xff;
assert!(key.decrypt(&sealed).is_none());
}
#[test]
fn target_key_is_stable_across_calls() {
let dir = TempDir::new().unwrap();
let first = TargetKey::ensure(dir.path()).expect("mint");
let sealed = first.encrypt(b"value").expect("encrypt");
let second = TargetKey::ensure(dir.path()).expect("reuse");
// Re-running import over the same directory must not orphan what the
// previous run wrote.
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
assert_eq!(
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
second.encrypt(b"probe").map(|v| v[..3].to_vec())
);
let mut keyring = SourceKeyring::default();
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
install_host_key(&mut keyring, &contents);
match keyring.decrypt(&sealed) {
Decrypted::Value(v) => assert_eq!(v, b"value"),
_ => panic!("target key must round-trip through the source keyring"),
}
}
#[test]
fn minted_key_matches_wayfern_file_format() {
let dir = TempDir::new().unwrap();
TargetKey::ensure(dir.path()).expect("mint");
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
#[cfg(target_os = "windows")]
assert_eq!(
contents.len(),
32,
"DPAPIKeyProvider only adopts a 32-byte portable key"
);
#[cfg(not(target_os = "windows"))]
{
// Wayfern writes base64(16 random bytes) = 24 ASCII chars.
assert_eq!(contents.len(), 24);
let text = String::from_utf8(contents).expect("ascii");
assert!(
base64::engine::general_purpose::STANDARD
.decode(&text)
.map(|b| b.len())
== Ok(16),
"expected base64 of 16 bytes, got {text}"
);
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
}
#[test]
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
let keyring = SourceKeyring::default();
assert!(matches!(
keyring.decrypt(b"plain cookie value"),
Decrypted::NotEncrypted
));
}
#[test]
fn app_bound_records_are_flagged_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v20".to_vec();
sealed.extend_from_slice(&[0u8; 40]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
assert!(
keyring.saw_app_bound.get(),
"v20 must be reported to the user, not silently dropped"
);
}
#[test]
fn missing_key_for_known_tag_is_unrecoverable() {
let keyring = SourceKeyring::default();
let mut sealed = b"v10".to_vec();
sealed.extend_from_slice(&[0u8; 32]);
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
}
#[test]
fn empty_password_fallback_recovers_the_record() {
// A record sealed with the empty-password key must still open when the
// keyring holds a different primary key, mirroring Chromium.
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
.encrypt(b"legacy")
.unwrap();
let mut stored = b"v10".to_vec();
stored.extend_from_slice(&sealed_body);
let keyring = SourceKeyring {
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
..Default::default()
};
match keyring.decrypt(&stored) {
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
_ => panic!("empty-password fallback must be attempted"),
}
}
/// Load the host-format key into a keyring under the host tag, for tests
/// that need to verify what we wrote is what Wayfern will read.
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
#[cfg(target_os = "windows")]
{
let bytes: [u8; 32] = contents.try_into().unwrap();
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
}
#[cfg(target_os = "macos")]
{
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
}
#[cfg(target_os = "linux")]
{
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
}
}
}
+109
View File
@@ -0,0 +1,109 @@
//! What an import actually carried across.
//!
//! Import is best-effort by nature: a locked keychain, a Windows App-Bound
//! cookie store or a schema too old for Chromium to migrate all mean some
//! subset does not survive, and none of them should abort the whole operation.
//! The report is how that stays honest — every skipped store is a counted
//! warning rather than a silent zero.
use serde::{Deserialize, Serialize};
/// Stable warning codes. The frontend maps these to
/// `importProfile.warnings.*`, so they are part of the API contract: rename one
/// and the user sees a missing translation.
pub mod warning {
/// The source browser's key could not be read, so cookies/passwords were
/// left encrypted and are unreadable in the new profile.
pub const SECRETS_NOT_MIGRATED: &str = "secretsNotMigrated";
/// Windows App-Bound Encryption (Chrome 127+). Unrecoverable by design.
pub const APP_BOUND_ENCRYPTED: &str = "appBoundEncrypted";
/// A store's schema predates what Chromium will migrate; it would have been
/// deleted on first launch, so it was skipped instead.
pub const STORE_TOO_OLD: &str = "storeTooOld";
/// A store's schema is newer than this Chromium can read.
pub const STORE_TOO_NEW: &str = "storeTooNew";
/// The source browser was running; databases were snapshotted but LevelDB
/// site data may be incomplete.
pub const SOURCE_BROWSER_RUNNING: &str = "sourceBrowserRunning";
/// Tracked preferences lost their MACs and will reset to defaults.
pub const SECURE_PREFERENCES_RESET: &str = "securePreferencesReset";
/// At least one extension could not be carried.
pub const EXTENSIONS_PARTIAL: &str = "extensionsPartial";
/// A database was unreadable and was skipped rather than copied corrupt.
pub const STORE_UNREADABLE: &str = "storeUnreadable";
}
/// Per-profile outcome, returned alongside each item in a batch import.
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
pub struct ProfileImportReport {
/// Cookies whose value is readable in the new profile.
pub cookies_migrated: usize,
/// Cookies carried over as rows but whose value could not be recovered.
pub cookies_unrecoverable: usize,
pub passwords_migrated: usize,
pub passwords_unrecoverable: usize,
/// Saved cards / IBANs / autofill secrets re-encrypted.
pub payment_methods_migrated: usize,
pub payment_methods_unrecoverable: usize,
pub extensions_migrated: usize,
pub history_entries: usize,
pub bookmarks: usize,
/// Origins with Local Storage data.
pub local_storage_origins: usize,
pub bytes_copied: u64,
/// Stable codes from [`warning`], deduplicated, in insertion order.
pub warnings: Vec<String>,
}
impl ProfileImportReport {
pub fn warn(&mut self, code: &str) {
if !self.warnings.iter().any(|w| w == code) {
self.warnings.push(code.to_string());
}
}
/// True when nothing readable came across. Used to decide whether the UI
/// should present the import as a success or as a warning.
pub fn is_empty_import(&self) -> bool {
self.cookies_migrated == 0
&& self.passwords_migrated == 0
&& self.history_entries == 0
&& self.bookmarks == 0
&& self.local_storage_origins == 0
&& self.extensions_migrated == 0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn warnings_are_deduplicated_in_order() {
let mut report = ProfileImportReport::default();
report.warn(warning::STORE_TOO_OLD);
report.warn(warning::SECRETS_NOT_MIGRATED);
report.warn(warning::STORE_TOO_OLD);
assert_eq!(
report.warnings,
vec![
warning::STORE_TOO_OLD.to_string(),
warning::SECRETS_NOT_MIGRATED.to_string()
]
);
}
#[test]
fn empty_import_detection_ignores_unrecoverable_counts() {
let mut report = ProfileImportReport {
cookies_unrecoverable: 500,
..Default::default()
};
assert!(
report.is_empty_import(),
"500 unreadable cookies is still nothing carried"
);
report.history_entries = 1;
assert!(!report.is_empty_import());
}
}
File diff suppressed because it is too large Load Diff