refactor: cleanup

This commit is contained in:
zhom committed 2026-10-14 16:25:09 +04:00
1 parent 88dde52c25
commit 49690a85be
9 files changed
+1393 -231

No files matched your search

+127
View File
@@ -2417,3 +2417,130 @@ test("custom launch arguments reach only their Wayfern profile", async () => {
await app.close();
}
});
test("a password-protected profile keeps what each session saved", async () => {
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
const app = appFromEnvironment("browser-sealed", {
seedVersionCache:
cachedFixtureVersion(process.env.DONUT_E2E_PROJECT_ROOT) ?? false,
wayfernTermsAccepted: false,
});
const password = "sealed-sessions";
let cdp;
let browserPid;
try {
const prepared = await prepareWayfern(
app,
process.env.DONUT_E2E_PROJECT_ROOT,
);
if (!app.session) await app.start();
await app.invoke("accept_wayfern_terms");
const profile = await createRealProfile(
app,
prepared.version,
"Sealed Sessions",
);
await app.invoke("set_profile_password", {
profileId: profile.id,
password,
});
const sealedDir = path.join(
app.dataRoot,
"data",
"profiles",
profile.id,
"profile",
);
const current = async () =>
(await app.invoke("list_browser_profiles")).find(
(p) => p.id === profile.id,
);
const saved = [];
for (let session = 0; session < 3; session++) {
if (await app.invoke("is_profile_locked", { profileId: profile.id })) {
await app.invoke("unlock_profile", { profileId: profile.id, password });
}
const launched = await app.invoke("launch_browser_profile", {
profile: await current(),
url: `${fixtureUrl}/sealed`,
});
browserPid = launched.process_id;
const port = Number(
processCommandLine(browserPid).match(
/--remote-debugging-port=(\d+)/,
)[1],
);
cdp = await CdpClient.connect(port);
await cdp.waitFor(`document.title === "Donut E2E Browser Fixture"`, {
description: "fixture page title",
});
const cookies = await cdp.evaluate("document.cookie");
for (const name of saved) {
assert.match(
cookies,
new RegExp(`${name}=kept`),
`session ${session} lost the cookie session ${name} saved`,
);
}
const name = `sealed_${session}`;
await cdp.evaluate(
`document.cookie = "${name}=kept; Max-Age=86400; Path=/"`,
);
saved.push(name);
cdp.close();
cdp = null;
// The cookie store commits every 30 s or at a clean shutdown, and a
// browser asked to close in its first seconds does not shut down.
await new Promise((resolve) => setTimeout(resolve, 32_000));
await app.invoke("kill_browser_profile", { profile: await current() });
await waitForProcessExit(app, browserPid);
browserPid = null;
assert.equal(
await app.invoke("is_profile_locked", { profileId: profile.id }),
true,
"a quit is sealed and its key forgotten before Stop returns",
);
const names = await readdir(sealedDir);
assert.ok(
names.every((n) => /^[\w-]{32}$/.test(n) || n === ".donut-pw-verify"),
`only sealed files belong in the profile directory: ${names}`,
);
}
const logDir = path.join(app.dataRoot, "logs");
const logText = (
await Promise.all(
(
await readdir(logDir)
)
.filter((n) => n.endsWith(".log"))
.map((n) => readFile(path.join(logDir, n), "utf8")),
)
).join("\n");
assert.doesNotMatch(logText, /Cookie decryption check failed/);
assert.doesNotMatch(logText, /Profile re-encryption failed/);
assert.equal(
logText.match(
new RegExp(`Profile re-encrypted profile=${profile.id} `, "g"),
)?.length,
3,
"each quit is sealed exactly once",
);
await app.invoke("delete_profile", { profileId: profile.id });
} catch (error) {
await app.capture("failure");
throw error;
} finally {
cdp?.close();
if (app.session && browserPid && processExists(browserPid)) {
const profile = (
await app.invoke("list_browser_profiles").catch(() => [])
).find((item) => item.process_id === browserPid);
if (profile)
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
}
await app.close();
}
});
+21 -10
View File
@@ -667,18 +667,29 @@ impl BrowserRunner {
// `match_profile_fingerprint_to_exit`.
// Create ephemeral dir for ephemeral or password-protected profiles
if profile.password_protected {
crate::profile::password::prepare_for_launch(profile)
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
} else if profile.ephemeral {
crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
}
let decrypted_copy = if profile.password_protected {
let protected = profile.clone();
let prepared = tokio::task::spawn_blocking(move || {
crate::profile::password::prepare_for_launch(&protected)
})
.await
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.to_string().into() })?
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
Some(prepared)
} else {
if profile.ephemeral {
crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
}
None
};
// Get profile path for Wayfern
// Get profile path for Wayfern. A protected profile runs only on its
// decrypted copy, never on the sealed directory.
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path =
crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir);
let profile_data_path = decrypted_copy.unwrap_or_else(|| {
crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir)
});
// Profiles imported by builds before the layout fix have their content at
// the user-data-dir root instead of under `Default/`, so the browser has
+19
View File
@@ -3194,6 +3194,25 @@ pub fn run_with_builder(
last_state != is_running || (!is_running && had_pid);
if should_emit {
// A Stop seals its own quit and a launch makes the profile
// run again; this exit is handled once neither is under way.
let _launch_guard = if is_running {
None
} else {
let Some(guard) =
crate::browser_runner::try_lock_profile_launch(&profile_id).await
else {
continue;
};
if runner
.check_browser_status(app_handle_status.clone(), &profile)
.await
.unwrap_or(false)
{
continue;
}
Some(guard)
};
log::debug!(
"Browser running state changed profile={profile_id} running={is_running} was={last_state}"
);
+180 -42
View File
@@ -8,7 +8,7 @@
//! The plaintext relpath is encoded inside the ciphertext so a launch can
//! reconstruct the directory tree without a separate manifest.
//!
//! Wrong password fails the AES-GCM auth tag on the first decrypt, which
//! Wrong password fails the AES-GCM auth tag on the verifier file, which
//! doubles as password verification.
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
@@ -30,6 +30,132 @@ const HMAC_FILENAME_LEN: usize = 32;
const VERIFY_FILE_NAME: &str = ".donut-pw-verify";
const VERIFY_FILE_PATH: &str = "__donut_pw_verify__";
/// Extension of a file `atomic_write` has not renamed into place yet. One left
/// by a crash is never a sealed file.
const TMP_EXTENSION: &str = "donut-tmp";
/// What a sealed copy leaves out: caches the browser rebuilds, lock and temp
/// files, crash and metrics dumps, and the files Donut writes again before
/// every launch. Everything else is kept, the SQLite journals and write-ahead
/// logs included: without them a database the browser did not close cleanly
/// opens torn instead of rolled back.
pub const SEALED_EXCLUDE_PATTERNS: &[&str] = &[
"**/Cache/**",
"**/Code Cache/**",
"**/GPUCache/**",
"**/GrShaderCache/**",
"**/ShaderCache/**",
"**/DawnCache/**",
"**/DawnGraphiteCache/**",
"**/DawnWebGPUCache/**",
"**/GraphiteDawnCache/**",
"**/Service Worker/CacheStorage/**",
"**/Service Worker/ScriptCache/**",
"**/blob_storage/**",
"**/Crashpad/**",
"**/Crash Reports/**",
"**/BrowserMetrics*",
"**/BrowserMetrics/**",
"**/optimization_guide_model_store/**",
"**/Safe Browsing/**",
"**/component_crx_cache/**",
"**/cache2/**",
"**/startupCache/**",
"**/safebrowsing/**",
"**/storage/temporary/**",
"**/storage/default/*/cache/**",
"**/datareporting/**",
"**/saved-telemetry-pings/**",
"**/crashes/**",
"**/minidumps/**",
"**/*-shm",
"**/LOCK",
"**/LOG",
"**/LOG.old",
"**/SingletonLock",
"**/SingletonSocket",
"**/SingletonCookie",
"**/.DS_Store",
"*.tmp",
"**/*.donut-tmp",
"**/wayfern-identity.json",
"**/wayfern-persona.json",
BASELINE_FILE_NAME,
];
/// The file in a decrypted copy that records what the sealed copy holds. It is
/// written only once the copy is complete and removed before the copy is
/// erased, so a directory without it is never mistaken for a profile.
pub const BASELINE_FILE_NAME: &str = ".donut-baseline.json";
/// A file's size and modification time, enough to tell that it changed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FileStamp {
secs: u64,
nanos: u32,
len: u64,
}
impl FileStamp {
fn of(metadata: &std::fs::Metadata) -> Option<Self> {
let modified = metadata
.modified()
.ok()?
.duration_since(SystemTime::UNIX_EPOCH)
.ok()?;
Some(Self {
secs: modified.as_secs(),
nanos: modified.subsec_nanos(),
len: metadata.len(),
})
}
}
/// The stamp of every file of a decrypted copy, by plaintext relpath, as of the
/// moment the sealed copy last held the same bytes.
pub type Baseline = HashMap<String, FileStamp>;
/// The result of decrypting a sealed copy.
pub struct Decrypted {
pub baseline: Baseline,
/// Sealed files that did not open with the right key: damaged, and skipped.
pub unreadable: usize,
}
/// The stamp of the sealed copy as a whole. Every seal and every password
/// change rewrites the verifier, and a sync download replaces it, so a new
/// stamp means the sealed copy changed.
pub fn sealed_stamp(encrypted_dir: &Path) -> Option<FileStamp> {
FileStamp::of(&std::fs::metadata(encrypted_dir.join(VERIFY_FILE_NAME)).ok()?)
}
/// Whether `plaintext_dir` differs from `baseline`: a file added, changed or
/// removed.
pub fn has_changes(
plaintext_dir: &Path,
exclude_patterns: &[&str],
baseline: &Baseline,
) -> PasswordResult<bool> {
let excludes = build_excludes(exclude_patterns);
let mut files = Vec::new();
walk_files(plaintext_dir, plaintext_dir, &excludes, &mut files)?;
if files.len() != baseline.len() {
return Ok(true);
}
for (relpath, abs) in files {
if FileStamp::of(&std::fs::metadata(&abs)?).as_ref() != baseline.get(&relpath) {
return Ok(true);
}
}
Ok(false)
}
fn is_tmp_name(name: &str) -> bool {
Path::new(name)
.extension()
.is_some_and(|ext| ext == TMP_EXTENSION)
}
/// In-memory cache of derived per-profile encryption keys, keyed by profile UUID.
/// Only populated while a profile is unlocked / running. Never persisted.
static KEY_CACHE: std::sync::LazyLock<Mutex<HashMap<uuid::Uuid, [u8; 32]>>> =
@@ -128,10 +254,9 @@ fn walk_files(
continue;
}
let metadata = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
// An error, never a skip: a file left out here is a file the sealed copy
// deletes.
let metadata = entry.metadata()?;
if metadata.is_dir() {
walk_files(base, &path, excludes, out)?;
@@ -142,11 +267,11 @@ fn walk_files(
Ok(())
}
fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
pub(crate) fn atomic_write(path: &Path, data: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension("donut-tmp");
let tmp = path.with_extension(TMP_EXTENSION);
std::fs::write(&tmp, data)?;
std::fs::rename(&tmp, path)
}
@@ -205,23 +330,25 @@ pub fn encrypt_profile_dir(
}
/// Decrypt every file in `encrypted_dir` back into `plaintext_dir` (which is
/// created if missing). Returns the per-file mtimes captured after writing,
/// keyed by plaintext relpath. Caller can use them as the "before-launch"
/// snapshot to skip unchanged files on re-encrypt.
/// created if missing), and return the baseline of what it wrote.
///
/// The key is checked against the verifier first, so a file that does not
/// open after that is damaged, not a wrong password: it is skipped and counted
/// rather than failing the whole profile.
pub fn decrypt_profile_dir(
key: &[u8; 32],
encrypted_dir: &Path,
plaintext_dir: &Path,
) -> PasswordResult<HashMap<String, SystemTime>> {
) -> PasswordResult<Decrypted> {
verify_key_against_dir(key, encrypted_dir)?;
std::fs::create_dir_all(plaintext_dir)?;
let mut mtimes = HashMap::new();
let mut decrypted = Decrypted {
baseline: Baseline::new(),
unreadable: 0,
};
let entries: Vec<_> = std::fs::read_dir(encrypted_dir)?
.filter_map(|r| r.ok())
.collect();
for entry in entries {
let path = entry.path();
for entry in std::fs::read_dir(encrypted_dir)? {
let path = entry?.path();
if !path.is_file() {
continue;
}
@@ -229,37 +356,40 @@ pub fn decrypt_profile_dir(
Some(n) => n,
None => continue,
};
if name == VERIFY_FILE_NAME {
if name == VERIFY_FILE_NAME || is_tmp_name(name) {
continue;
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(key, &bytes)?;
let Ok((relpath, content)) = decrypt_profile_file(key, &bytes) else {
decrypted.unreadable += 1;
continue;
};
let dest = plaintext_dir.join(&relpath);
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&dest, &content)?;
if let Ok(m) = dest.metadata().and_then(|m| m.modified()) {
mtimes.insert(relpath, m);
if let Some(stamp) = FileStamp::of(&std::fs::metadata(&dest)?) {
decrypted.baseline.insert(relpath, stamp);
}
}
Ok(mtimes)
Ok(decrypted)
}
/// Re-encrypt the contents of `plaintext_dir` back into `encrypted_dir`,
/// preserving on-disk filenames for files whose plaintext content didn't
/// change. Returns the number of files re-encrypted.
/// change. Returns the number of files re-encrypted and the new baseline.
///
/// `before_launch_mtimes` is the snapshot captured by `decrypt_profile_dir`.
/// Files whose mtime hasn't moved are left untouched on disk.
/// A file whose stamp matches `baseline` is already sealed and is left alone.
/// A sealed file whose plaintext is gone is deleted.
pub fn reencrypt_changed_files(
key: &[u8; 32],
plaintext_dir: &Path,
encrypted_dir: &Path,
exclude_patterns: &[&str],
before_launch_mtimes: &HashMap<String, SystemTime>,
) -> PasswordResult<usize> {
baseline: &Baseline,
) -> PasswordResult<(usize, Baseline)> {
std::fs::create_dir_all(encrypted_dir)?;
let excludes = build_excludes(exclude_patterns);
@@ -269,17 +399,20 @@ pub fn reencrypt_changed_files(
}
let mut current_paths: HashSet<String> = HashSet::new();
let mut sealed = Baseline::new();
let mut rewrote = 0usize;
for (relpath, abs) in current_files {
current_paths.insert(relpath.clone());
let cur_mtime = abs.metadata().and_then(|m| m.modified()).ok();
let unchanged = match (cur_mtime, before_launch_mtimes.get(&relpath)) {
(Some(now), Some(before)) => now == *before,
_ => false,
};
if unchanged {
continue;
// Taken before the read, so a write that lands during it shows up as a
// change next time rather than being missed. A file with no usable time
// gets no entry and is sealed again every time.
let stamp = FileStamp::of(&std::fs::metadata(&abs)?);
if let Some(stamp) = stamp {
sealed.insert(relpath.clone(), stamp);
if baseline.get(&relpath) == Some(&stamp) {
continue;
}
}
let bytes = std::fs::read(&abs)?;
@@ -313,21 +446,24 @@ pub fn reencrypt_changed_files(
}
write_verifier(key, encrypted_dir)?;
Ok(rewrote)
Ok((rewrote, sealed))
}
/// Re-encrypt every file under `encrypted_dir` from `old_key` to `new_key` in
/// place. Used when changing a profile password without launching it.
/// Returns how many damaged files were dropped: they open with neither key.
pub fn rekey_profile_dir(
old_key: &[u8; 32],
new_key: &[u8; 32],
encrypted_dir: &Path,
) -> PasswordResult<()> {
) -> PasswordResult<usize> {
verify_key_against_dir(old_key, encrypted_dir)?;
let entries: Vec<_> = std::fs::read_dir(encrypted_dir)?
.filter_map(|r| r.ok())
.collect();
let mut decrypted: Vec<(String, Vec<u8>)> = Vec::new();
let mut unreadable = 0usize;
for entry in &entries {
let path = entry.path();
if !path.is_file() {
@@ -337,15 +473,17 @@ pub fn rekey_profile_dir(
Some(n) => n,
None => continue,
};
if name == VERIFY_FILE_NAME {
if name == VERIFY_FILE_NAME || is_tmp_name(name) {
continue;
}
let bytes = std::fs::read(&path)?;
let (relpath, content) = decrypt_profile_file(old_key, &bytes)?;
decrypted.push((relpath, content));
match decrypt_profile_file(old_key, &bytes) {
Ok(file) => decrypted.push(file),
Err(_) => unreadable += 1,
}
}
// Decryption succeeded for every file; safe to rewrite the directory.
// Every readable file is in memory; safe to rewrite the directory.
for entry in entries {
let path = entry.path();
if path.is_file() {
@@ -360,7 +498,7 @@ pub fn rekey_profile_dir(
}
write_verifier(new_key, encrypted_dir)?;
Ok(())
Ok(unreadable)
}
// ---------- key cache ----------
+75 -8
View File
@@ -88,8 +88,9 @@ fn test_dir_roundtrip() {
assert!(enc.join(VERIFY_FILE_NAME).exists());
let restored = work.path().join("restored");
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
assert_eq!(mtimes.len(), 3);
let decrypted = decrypt_profile_dir(&key, &enc, &restored).unwrap();
assert_eq!(decrypted.baseline.len(), 3);
assert_eq!(decrypted.unreadable, 0);
assert_eq!(
std::fs::read(restored.join("Default/Cookies")).unwrap(),
@@ -118,11 +119,11 @@ fn test_dir_excludes() {
encrypt_profile_dir(&key, &plain, &enc, &["**/Cache/**"]).unwrap();
let restored = work.path().join("restored");
let mtimes = decrypt_profile_dir(&key, &enc, &restored).unwrap();
let baseline = decrypt_profile_dir(&key, &enc, &restored).unwrap().baseline;
// Only Cookies (1 file) should be present, not Cache contents
assert_eq!(mtimes.len(), 1);
assert!(mtimes.contains_key("Default/Cookies"));
assert_eq!(baseline.len(), 1);
assert!(baseline.contains_key("Default/Cookies"));
assert!(restored.join("Default/Cookies").exists());
assert!(!restored.join("Default/Cache/data").exists());
}
@@ -156,7 +157,7 @@ fn test_reencrypt_skips_unchanged() {
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let restored = work.path().join("restored");
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap().baseline;
// Capture pre-rewrite ciphertext bytes
let name_a = hmac_filename(&key, "a");
@@ -168,8 +169,14 @@ fn test_reencrypt_skips_unchanged() {
std::thread::sleep(std::time::Duration::from_millis(1100));
std::fs::write(restored.join("a"), b"AAA-CHANGED").unwrap();
let rewrote = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
let (rewrote, sealed) = reencrypt_changed_files(&key, &restored, &enc, &[], &snapshot).unwrap();
assert_eq!(rewrote, 1);
assert_eq!(sealed["b"], snapshot["b"]);
assert_ne!(sealed["a"], snapshot["a"]);
// Sealed again from the new baseline, nothing has changed.
let (rewrote, _) = reencrypt_changed_files(&key, &restored, &enc, &[], &sealed).unwrap();
assert_eq!(rewrote, 0);
let cipher_a_after = std::fs::read(enc.join(&name_a)).unwrap();
let cipher_b_after = std::fs::read(enc.join(&name_b)).unwrap();
@@ -195,7 +202,7 @@ fn test_reencrypt_handles_added_and_removed() {
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
let restored = work.path().join("restored");
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap();
let snapshot = decrypt_profile_dir(&key, &enc, &restored).unwrap().baseline;
std::fs::remove_file(restored.join("delete")).unwrap();
std::fs::write(restored.join("new"), b"n").unwrap();
@@ -289,3 +296,63 @@ fn test_unlock_helper() {
assert!(has_cached_key(&id));
drop_cached_key(&id);
}
#[test]
fn decrypt_checks_the_key_and_skips_damaged_and_unfinished_files() {
let key = make_key();
let work = TempDir::new().unwrap();
let plain = work.path().join("plain");
let enc = work.path().join("enc");
std::fs::create_dir_all(&plain).unwrap();
std::fs::write(plain.join("good"), b"good").unwrap();
encrypt_profile_dir(&key, &plain, &enc, &[]).unwrap();
std::fs::write(enc.join("damaged"), b"not ciphertext at all").unwrap();
// What a crash in the middle of atomic_write leaves.
std::fs::write(
enc
.join(hmac_filename(&key, "good"))
.with_extension("donut-tmp"),
b"partial",
)
.unwrap();
let out = work.path().join("out");
assert!(matches!(
decrypt_profile_dir(&make_key(), &enc, &out),
Err(PasswordError::WrongPassword)
));
assert!(!out.exists(), "a wrong key must write nothing");
let decrypted = decrypt_profile_dir(&key, &enc, &out).unwrap();
assert_eq!(decrypted.unreadable, 1);
assert_eq!(decrypted.baseline.len(), 1);
assert_eq!(std::fs::read(out.join("good")).unwrap(), b"good");
}
#[test]
fn the_sealed_exclusions_keep_what_a_database_needs() {
let excludes = build_excludes(SEALED_EXCLUDE_PATTERNS);
for kept in [
"Default/Network/Cookies",
"Default/Network/Cookies-journal",
"Default/History-wal",
"Default/Secure Preferences",
"Default/Session Storage/000003.log",
"Local State",
"os_crypt_key",
] {
assert!(!excludes.is_match(kept), "{kept} must be sealed");
}
for dropped in [
"Default/Cache/Cache_Data/index",
"Default/Code Cache/js/index",
"Default/History-shm",
"Default/Local Storage/leveldb/LOCK",
"SingletonLock",
"wayfern-identity.json",
BASELINE_FILE_NAME,
"Default/x.donut-tmp",
] {
assert!(excludes.is_match(dropped), "{dropped} must not be sealed");
}
}
+6 -7
View File
@@ -857,10 +857,7 @@ impl ProfileManager {
// to. The running-browser guard above only rejects a live process, and
// the keep-decrypted path deliberately clears process_id while leaving
// the plaintext tree populated. No-ops when there is no ephemeral dir.
crate::ephemeral_dirs::remove_ephemeral_dir(profile_id);
if profile.password_protected {
crate::profile::encryption::drop_cached_key(&profile.id);
}
crate::profile::password::discard_copy(&profile.id);
let was_sync_enabled = profile.is_sync_enabled();
let profiles_dir = self.get_profiles_dir();
@@ -1067,9 +1064,11 @@ impl ProfileManager {
&self,
profile_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
crate::ephemeral_dirs::remove_ephemeral_dir(profile_id);
if let Ok(uuid) = uuid::Uuid::parse_str(profile_id) {
crate::profile::encryption::drop_cached_key(&uuid);
match uuid::Uuid::parse_str(profile_id) {
Ok(uuid) => crate::profile::password::discard_copy(&uuid),
Err(_) => {
crate::ephemeral_dirs::remove_ephemeral_dir(profile_id);
}
}
self.forget_profile_side_state(profile_id);
let profiles_dir = self.get_profiles_dir();
+373 -159
View File
@@ -9,16 +9,17 @@
use crate::events;
use crate::profile::encryption::{
cache_key, decrypt_profile_dir, drop_cached_key, encrypt_profile_dir, fresh_salt, get_cached_key,
has_cached_key, rekey_profile_dir, unlock as unlock_dir, verify_key_against_dir,
has_cached_key, has_changes, reencrypt_changed_files, rekey_profile_dir, sealed_stamp,
unlock as unlock_dir, verify_key_against_dir, Baseline, FileStamp, PasswordError,
BASELINE_FILE_NAME, SEALED_EXCLUDE_PATTERNS,
};
use crate::profile::ProfileManager;
use crate::sync::encryption::derive_profile_key;
use crate::sync::manifest::DEFAULT_EXCLUDE_PATTERNS;
use serde_json::json;
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use std::time::{Duration, Instant, SystemTime};
/// Build a JSON error payload with just a code.
fn err_code(code: &'static str) -> String {
@@ -40,16 +41,23 @@ fn err_internal(detail: impl std::fmt::Display) -> String {
err_with("INTERNAL_ERROR", &[("detail", detail.to_string())])
}
/// Per-profile snapshot of plaintext file mtimes captured at launch time.
/// Used by `complete_after_quit` to skip re-encrypting unchanged files.
static LAUNCH_SNAPSHOTS: std::sync::LazyLock<
Mutex<HashMap<uuid::Uuid, HashMap<String, SystemTime>>>,
> = std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
/// Profile IDs whose ephemeral dir is currently populated and matches the
/// on-disk encrypted state, so we can skip re-decrypting on the next launch
/// when `keep_decrypted_profiles_in_ram` is enabled.
static POPULATED_EPHEMERAL: std::sync::LazyLock<Mutex<HashSet<uuid::Uuid>>> =
std::sync::LazyLock::new(|| Mutex::new(HashSet::new()));
/// The error a password command returns when the decrypted copy it must seal
/// first cannot be sealed. The key is the one the command just verified, so
/// `NoKey` cannot happen here.
fn seal_error(error: SealError) -> String {
match error {
SealError::InUse(_) => err_code("PROFILE_RUNNING"),
SealError::NoKey => err_code("PROFILE_LOCKED"),
SealError::Failed(e) => err_internal(e),
}
}
/// Per-profile lock around everything that touches a profile's decrypted copy
/// or rewrites its sealed one. A quit is sealed by both the Stop button and the
/// exit watcher; unserialized, one of them read files the other was already
/// zeroing, and the sealed copy kept the zeros.
static COPY_LOCKS: std::sync::LazyLock<Mutex<HashMap<uuid::Uuid, Arc<Mutex<()>>>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
/// Per-profile failed unlock attempt tracking for rate-limiting.
static FAILED_ATTEMPTS: std::sync::LazyLock<Mutex<HashMap<uuid::Uuid, FailureRecord>>> =
std::sync::LazyLock::new(|| Mutex::new(HashMap::new()));
@@ -80,6 +88,16 @@ fn attempt_lock(profile_id: &uuid::Uuid) -> Arc<tokio::sync::Mutex<()>> {
.clone()
}
/// The copy lock for one profile. Held only on blocking threads: preparing a
/// launch, sealing after a quit, and the password commands.
fn copy_lock(profile_id: &uuid::Uuid) -> Arc<Mutex<()>> {
let mut guard = COPY_LOCKS
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.retain(|_, lock| Arc::strong_count(lock) > 1);
guard.entry(*profile_id).or_default().clone()
}
#[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize)]
struct FailureRecord {
count: u32,
@@ -290,7 +308,7 @@ pub async fn set_profile_password(profile_id: String, password: String) -> Resul
if staging.exists() {
let _ = std::fs::remove_dir_all(&staging);
}
encrypt_profile_dir(&key, &plaintext_dir, &staging, DEFAULT_EXCLUDE_PATTERNS)
encrypt_profile_dir(&key, &plaintext_dir, &staging, SEALED_EXCLUDE_PATTERNS)
.map_err(err_internal)?;
// Move plaintext aside, swap in encrypted, then delete plaintext.
@@ -405,9 +423,24 @@ pub async fn lock_profile(profile_id: String) -> Result<(), String> {
{
return Err(err_code("PROFILE_RUNNING"));
}
{
let lock = copy_lock(&id);
let _copy = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let key = get_cached_key(&id);
match seal_copy(&id, &profile_data_dir(&profile), key.as_ref(), false) {
// Sealed and erased, or changes that wait for the next unlock.
Ok(Some(_)) | Err(SealError::NoKey) => {}
// Debris or nothing: either way there is nothing to keep.
Ok(None) => {
crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string());
}
Err(SealError::InUse(_)) => return Err(err_code("PROFILE_RUNNING")),
Err(SealError::Failed(e)) => return Err(err_internal(e)),
}
}
drop_cached_key(&id);
// Purge any leftover ephemeral dir in case keep_decrypted_profiles_in_ram was on.
crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string());
emit_profiles_changed();
Ok(())
}
@@ -459,7 +492,19 @@ pub async fn change_profile_password(
let new_salt = fresh_salt();
let new_key = derive_profile_key(&new_password, &new_salt).map_err(err_internal)?;
rekey_profile_dir(&old_key, &new_key, &dir).map_err(err_internal)?;
{
let lock = copy_lock(&id);
let _copy = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
// A copy with changes the old key has not sealed yet would otherwise keep
// them out of the sealed profile for good.
seal_copy(&id, &dir, Some(&old_key), true).map_err(seal_error)?;
let damaged = rekey_profile_dir(&old_key, &new_key, &dir).map_err(err_internal)?;
if damaged > 0 {
log::error!("Damaged sealed files dropped on password change profile={id} count={damaged}");
}
}
profile.encryption_salt = Some(new_salt);
ProfileManager::instance()
@@ -512,11 +557,26 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
}
clear_failed_attempts(&id);
let lock = copy_lock(&id);
let _copy = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
seal_copy(&id, &encrypted_dir, Some(&key), false).map_err(seal_error)?;
// Debris an earlier erase left; the profile is about to be plaintext where
// it lives, and no copy of it may linger elsewhere.
crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string());
let staging = encrypted_dir.with_extension("decrypting");
if staging.exists() {
let _ = std::fs::remove_dir_all(&staging);
}
decrypt_profile_dir(&key, &encrypted_dir, &staging).map_err(err_internal)?;
let decrypted = decrypt_profile_dir(&key, &encrypted_dir, &staging).map_err(err_internal)?;
if decrypted.unreadable > 0 {
log::error!(
"Damaged sealed files dropped on password removal profile={id} count={}",
decrypted.unreadable
);
}
let backup = encrypted_dir.with_extension("encrypted-backup");
if backup.exists() {
@@ -546,105 +606,272 @@ pub async fn remove_profile_password(profile_id: String, password: String) -> Re
Ok(())
}
// ---------- helpers used by browser_runner ----------
// ---------- the decrypted copy a launch runs on ----------
//
// A launch decrypts the sealed profile into a copy the browser runs on, and the
// quit seals the copy's changes back. The copy holds `BASELINE_FILE_NAME`, the
// stamps of its files as of the last time the sealed copy matched it, from the
// moment it is complete until just before it is erased. So a copy with a
// baseline is a whole profile whose newer files still need sealing, and a copy
// without one is debris from an interrupted decrypt or erase.
/// Capture a per-file mtime snapshot of the given decrypted dir.
fn snapshot_mtimes(plaintext_dir: &Path) -> HashMap<String, SystemTime> {
let mut out: HashMap<String, SystemTime> = HashMap::new();
fn walk(
base: &Path,
current: &Path,
out: &mut HashMap<String, SystemTime>,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(current)? {
let entry = entry?;
let path = entry.path();
let meta = entry.metadata()?;
if meta.is_dir() {
walk(base, &path, out)?;
} else if meta.is_file() {
let rel = path
.strip_prefix(base)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
if let Ok(m) = meta.modified() {
out.insert(rel, m);
}
}
}
Ok(())
}
let _ = walk(plaintext_dir, plaintext_dir, &mut out);
out
/// What `BASELINE_FILE_NAME` holds.
#[derive(serde::Serialize, serde::Deserialize)]
struct CopyRecord {
files: Baseline,
/// The sealed copy's stamp when it last matched this copy.
sealed: Option<FileStamp>,
}
/// Decrypt a password-protected profile's encrypted dir into an ephemeral
/// dir, take a mtime snapshot for diff-on-quit, and return the ephemeral
/// path the browser should launch from.
/// How long a seal waits for the browser's processes to let go of the copy.
const RELEASE_WAIT: Duration = Duration::from_secs(10);
/// How many times a failed seal is retried, a little later each time.
const SEAL_ATTEMPTS: u32 = 3;
fn read_record(dir: &Path) -> Option<CopyRecord> {
let bytes = std::fs::read(dir.join(BASELINE_FILE_NAME)).ok()?;
serde_json::from_slice(&bytes).ok()
}
/// Record that the copy in `dir` matches the sealed copy in `store` as of now.
fn write_record(dir: &Path, files: Baseline, store: &Path) -> std::io::Result<()> {
let record = CopyRecord {
files,
sealed: sealed_stamp(store),
};
let json = serde_json::to_vec(&record).map_err(std::io::Error::other)?;
crate::profile::encryption::atomic_write(&dir.join(BASELINE_FILE_NAME), &json)
}
/// Processes started on `dir` as their user data dir, child processes
/// included: on Windows the network service still holds the cookie database
/// for a moment after the browser process has exited.
fn processes_using(dir: &Path) -> Vec<u32> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System, UpdateKind};
let Ok(dir) = dir.canonicalize() else {
return Vec::new();
};
let system = System::new_with_specifics(
RefreshKind::nothing()
.with_processes(ProcessRefreshKind::nothing().with_cmd(UpdateKind::Always)),
);
system
.processes()
.iter()
.filter(|(_, process)| {
process.cmd().iter().any(|arg| {
arg
.to_str()
.and_then(|arg| arg.strip_prefix("--user-data-dir="))
.and_then(|value| Path::new(value.trim_matches('"')).canonicalize().ok())
.is_some_and(|value| value == dir)
})
})
.map(|(pid, _)| pid.as_u32())
.collect()
}
/// Wait until no process runs on `dir`. Returns the ones still there at the
/// limit.
fn wait_until_released(dir: &Path) -> Result<(), Vec<u32>> {
let started = Instant::now();
loop {
let pids = processes_using(dir);
if pids.is_empty() {
return Ok(());
}
if started.elapsed() >= RELEASE_WAIT {
return Err(pids);
}
std::thread::sleep(Duration::from_millis(250));
}
}
/// Seal the copy's changes into `store` and record the new baseline.
fn seal(key: &[u8; 32], dir: &Path, store: &Path, baseline: &Baseline) -> Result<usize, String> {
let mut attempt = 1;
loop {
let sealed = reencrypt_changed_files(key, dir, store, SEALED_EXCLUDE_PATTERNS, baseline)
.map_err(|e| e.to_string())
.and_then(|(rewrote, sealed)| {
write_record(dir, sealed, store)
.map(|()| rewrote)
.map_err(|e| e.to_string())
});
match sealed {
Err(_) if attempt < SEAL_ATTEMPTS => {
std::thread::sleep(Duration::from_millis(500) * attempt);
attempt += 1;
}
result => return result,
}
}
}
/// Mark the copy as no longer a profile. Done before every erase, so an erase
/// that stops halfway leaves debris rather than something taken for one.
fn forget_record(dir: &Path) -> std::io::Result<()> {
match std::fs::remove_file(dir.join(BASELINE_FILE_NAME)) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
}
/// Erase the copy and forget the key.
fn erase_copy(profile_id: &uuid::Uuid, dir: &Path) {
if let Err(e) = forget_record(dir) {
log::error!(
"Decrypted copy kept, its baseline could not be removed profile={profile_id} err=\"{e}\""
);
return;
}
drop_cached_key(profile_id);
crate::ephemeral_dirs::remove_ephemeral_dir(&profile_id.to_string());
}
/// Erase a profile's decrypted copy, sealed or not, and forget its key: the
/// profile is being deleted.
pub fn discard_copy(profile_id: &uuid::Uuid) {
let lock = copy_lock(profile_id);
let _copy = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let id = profile_id.to_string();
if let Some(dir) = crate::ephemeral_dirs::get_ephemeral_dir(&id) {
if let Err(e) = forget_record(&dir) {
log::error!(
"Decrypted copy baseline not removed before erase profile={profile_id} err=\"{e}\""
);
}
}
drop_cached_key(profile_id);
crate::ephemeral_dirs::remove_ephemeral_dir(&id);
}
/// Why a copy was not sealed.
enum SealError {
/// The profile is locked; the copy waits for the next unlock.
NoKey,
/// A browser still runs on the copy.
InUse(Vec<u32>),
Failed(String),
}
/// Seal the profile's decrypted copy, if it has one with changes to seal, then
/// erase it unless `keep`. `Ok(None)` means there was no copy to seal: none was
/// made, or another caller already sealed and erased it. Hold the copy lock.
fn seal_copy(
profile_id: &uuid::Uuid,
store: &Path,
key: Option<&[u8; 32]>,
keep: bool,
) -> Result<Option<usize>, SealError> {
let Some(dir) = crate::ephemeral_dirs::get_ephemeral_dir(&profile_id.to_string()) else {
return Ok(None);
};
let Some(record) = read_record(&dir) else {
return Ok(None);
};
let key = key.ok_or(SealError::NoKey)?;
wait_until_released(&dir).map_err(SealError::InUse)?;
let started = Instant::now();
let rewrote = seal(key, &dir, store, &record.files).map_err(SealError::Failed)?;
log::info!(
"Profile re-encrypted profile={profile_id} changed_files={rewrote} keep_decrypted={keep} elapsed_ms={}",
started.elapsed().as_millis()
);
if !keep {
erase_copy(profile_id, &dir);
}
Ok(Some(rewrote))
}
/// Make the decrypted copy a launch runs on, and return its path.
///
/// A copy that is still there with a baseline is the newest state of the
/// profile, kept in RAM by the setting or left by a quit that could not seal
/// it: it is sealed and used as it is. Anything else is decrypted afresh.
///
/// Returns an error if the profile isn't unlocked yet — the frontend should
/// prompt for the password and call `unlock_profile` first.
pub fn prepare_for_launch(profile: &crate::profile::BrowserProfile) -> Result<PathBuf, String> {
let id = profile.id;
let key = get_cached_key(&id).ok_or_else(|| err_code("PROFILE_LOCKED"))?;
let store = profile_data_dir(profile);
let lock = copy_lock(&id);
let _copy = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let id_str = id.to_string();
let ephemeral = match crate::ephemeral_dirs::get_ephemeral_dir(&id_str) {
Some(p) => p,
let mut dir = match crate::ephemeral_dirs::get_ephemeral_dir(&id_str) {
Some(dir) => dir,
None => crate::ephemeral_dirs::create_ephemeral_dir(&id_str).map_err(err_internal)?,
};
let already_populated = POPULATED_EPHEMERAL
.lock()
.map(|g| g.contains(&id))
.unwrap_or(false);
let encrypted_dir = profile_data_dir(profile);
let snapshot = if already_populated && ephemeral_has_files(&ephemeral) {
// Reusing a kept-in-RAM copy from the previous session; just snapshot.
snapshot_mtimes(&ephemeral)
} else {
// Wipe any stale contents and re-decrypt.
if let Err(e) = clear_dir_contents(&ephemeral) {
log::warn!("Stale decrypted copy clear failed profile={id} err=\"{e}\"");
}
decrypt_profile_dir(&key, &encrypted_dir, &ephemeral).map_err(|e| match e {
crate::profile::encryption::PasswordError::WrongPassword => err_code("INCORRECT_PASSWORD"),
other => err_internal(other),
})?
};
if let Ok(mut guard) = LAUNCH_SNAPSHOTS.lock() {
guard.insert(id, snapshot);
}
if let Ok(mut guard) = POPULATED_EPHEMERAL.lock() {
guard.insert(id);
if let Err(pids) = wait_until_released(&dir) {
log::warn!(
"Launch refused, a browser still runs on the decrypted copy profile={id} pids={pids:?}"
);
return Err(err_code("PROFILE_RUNNING"));
}
Ok(ephemeral)
}
fn ephemeral_has_files(dir: &Path) -> bool {
std::fs::read_dir(dir)
.map(|mut iter| iter.next().is_some())
.unwrap_or(false)
}
fn clear_dir_contents(dir: &Path) -> std::io::Result<()> {
if !dir.exists() {
return Ok(());
}
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
std::fs::remove_dir_all(&path)?;
if let Some(record) = read_record(&dir) {
let replaced = record.sealed != sealed_stamp(&store);
if replaced && !has_changes(&dir, SEALED_EXCLUDE_PATTERNS, &record.files).unwrap_or(true) {
// Sync brought a newer sealed profile while this copy waited, unused.
log::info!("Kept decrypted copy replaced by a newer sealed profile profile={id}");
} else {
std::fs::remove_file(&path)?;
if replaced {
log::warn!(
"Sealed profile changed while its decrypted copy had changes, the copy's files win profile={id}"
);
}
// The copy is the newest state either way; a failed seal only means
// the quit seals more.
match seal(&key, &dir, &store, &record.files) {
Ok(0) => {}
Ok(rewrote) => {
log::info!("Unsealed profile changes re-encrypted profile={id} changed_files={rewrote}")
}
Err(e) => {
log::error!("Profile re-encryption before launch failed profile={id} err=\"{e}\"")
}
}
return Ok(dir);
}
}
Ok(())
if std::fs::read_dir(&dir).is_ok_and(|mut entries| entries.next().is_some()) {
if forget_record(&dir).is_err() || !crate::ephemeral_dirs::remove_ephemeral_dir(&id_str) {
return Err(err_internal(
"the previous decrypted copy could not be erased",
));
}
dir = crate::ephemeral_dirs::create_ephemeral_dir(&id_str).map_err(err_internal)?;
}
let decrypted = decrypt_profile_dir(&key, &store, &dir)
.map_err(|e| match e {
PasswordError::WrongPassword => err_code("INCORRECT_PASSWORD"),
other => err_internal(other),
})
.and_then(|decrypted| {
write_record(&dir, decrypted.baseline, &store)
.map(|()| decrypted.unreadable)
.map_err(err_internal)
});
let unreadable = match decrypted {
Ok(unreadable) => unreadable,
Err(e) => {
crate::ephemeral_dirs::remove_ephemeral_dir(&id_str);
return Err(e);
}
};
if unreadable > 0 {
log::error!("Damaged sealed files skipped profile={id} count={unreadable}");
}
Ok(dir)
}
fn read_keep_decrypted_setting() -> bool {
@@ -654,72 +881,54 @@ fn read_keep_decrypted_setting() -> bool {
.unwrap_or(false)
}
/// Synchronous core of `complete_after_quit`: re-encrypts ephemeral → disk
/// and (unless `keep_decrypted` is true) drops cached key + purges ephemeral.
/// Returns the number of files re-encrypted, or `None` if there was nothing
/// to do. Public for testability.
/// Seal the copy the browser just quit, then erase it unless `keep_decrypted`.
/// Returns the number of files re-encrypted, or `None` when nothing was sealed:
/// no copy, already sealed by the other caller of the same quit, or a failure,
/// in which case the copy and the key stay for the next launch to seal.
fn seal_after_quit(profile_id: uuid::Uuid, store: &Path, keep_decrypted: bool) -> Option<usize> {
let lock = copy_lock(&profile_id);
let _copy = lock
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let key = get_cached_key(&profile_id);
match seal_copy(&profile_id, store, key.as_ref(), keep_decrypted) {
Ok(rewrote) => rewrote,
Err(SealError::NoKey) => {
log::warn!("Profile changes kept until the profile is unlocked profile={profile_id}");
None
}
Err(SealError::InUse(pids)) => {
log::warn!(
"Profile not re-encrypted, a browser still runs on it profile={profile_id} pids={pids:?}"
);
None
}
Err(SealError::Failed(e)) => {
log::error!(
"Profile re-encryption failed, decrypted copy kept profile={profile_id} err=\"{e}\""
);
None
}
}
}
/// `complete_after_quit_and_wait` on the calling thread, for tests.
#[cfg(test)]
pub fn complete_after_quit_blocking(
profile: &crate::profile::BrowserProfile,
keep_decrypted: bool,
) -> Option<usize> {
use crate::profile::encryption::reencrypt_changed_files;
let id = profile.id;
if !profile.password_protected {
return None;
}
// Snapshot is an optimization (skip re-encrypting unchanged files). When
// it's missing — e.g. natural-exit detection firing twice, or status
// checker firing for a profile whose snapshot was already consumed — we
// fall back to treating every ephemeral file as new. Empty `before`
// forces all files through encrypt, which is slower but correct.
let snapshot = LAUNCH_SNAPSHOTS
.lock()
.ok()
.and_then(|mut g| g.remove(&id))
.unwrap_or_default();
let id_str = id.to_string();
let ephemeral = crate::ephemeral_dirs::get_ephemeral_dir(&id_str)?;
let encrypted = profile_data_dir(profile);
let key = get_cached_key(&id)?;
let result = match reencrypt_changed_files(
&key,
&ephemeral,
&encrypted,
DEFAULT_EXCLUDE_PATTERNS,
&snapshot,
) {
Ok(n) => {
log::info!(
"Profile re-encrypted profile={id} changed_files={n} keep_decrypted={keep_decrypted}"
);
Some(n)
}
Err(e) => {
log::error!("Profile re-encryption failed profile={id} err=\"{e}\"");
None
}
};
if !keep_decrypted {
drop_cached_key(&id);
if let Ok(mut guard) = POPULATED_EPHEMERAL.lock() {
guard.remove(&id);
}
crate::ephemeral_dirs::remove_ephemeral_dir(&id_str);
}
result
seal_after_quit(profile.id, &profile_data_dir(profile), keep_decrypted)
}
/// Re-encrypt a password-protected profile's ephemeral dir back to the
/// on-disk encrypted dir after the browser process exits. Optionally purges
/// the ephemeral dir + cached key based on the global setting. Returns the
/// number of files re-encrypted (`None` when nothing to do or the profile
/// isn't protected).
/// Re-encrypt a password-protected profile's decrypted copy back to the
/// on-disk encrypted dir after the browser process exits. Purges the copy and
/// the cached key unless the global setting keeps them. Returns the number of
/// files re-encrypted (`None` when nothing was sealed or the profile isn't
/// protected).
///
/// Callers that release a queued sync run after a browser quit MUST await
/// this future — releasing sync while re-encryption is still in-flight
@@ -732,9 +941,10 @@ pub async fn complete_after_quit_and_wait(
return None;
}
let keep_decrypted = read_keep_decrypted_setting();
let profile = profile.clone();
let store = profile_data_dir(profile);
let profile_id = profile.id;
tokio::task::spawn_blocking(move || complete_after_quit_blocking(&profile, keep_decrypted))
tokio::task::spawn_blocking(move || seal_after_quit(profile_id, &store, keep_decrypted))
.await
.unwrap_or_else(|e| {
log::error!("Profile re-encryption task panicked err=\"{e}\"");
@@ -745,3 +955,7 @@ pub async fn complete_after_quit_and_wait(
#[cfg(test)]
#[path = "password_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "password_browser_tests.rs"]
mod browser_tests;
@@ -0,0 +1,215 @@
use super::*;
use crate::cookie_manager::{chrome_decrypt, CookieManager};
use crate::profile::BrowserProfile;
use crate::wayfern_manager::WayfernManager;
use serde_json::{json, Value};
use std::time::Duration;
const PASSWORD: &str = "correct horse battery";
fn wayfern_executable() -> PathBuf {
let dir = std::env::var("DONUT_TEST_WAYFERN_DIR")
.ok()
.filter(|dir| !dir.trim().is_empty())
.expect("DONUT_TEST_WAYFERN_DIR is required");
crate::browser::create_browser(crate::browser::BrowserType::Wayfern)
.get_executable_path(Path::new(&dir))
.expect("DONUT_TEST_WAYFERN_DIR holds a Wayfern build")
}
/// Every encrypted cookie row of the profile, and how many of them the
/// profile's `os_crypt_key` opens.
fn decryptable_cookies(user_data_dir: &Path) -> (usize, usize) {
let path = CookieManager::wayfern_cookie_path(user_data_dir);
if !path.exists() {
return (0, 0);
}
let conn =
rusqlite::Connection::open_with_flags(&path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.unwrap();
let mut stmt = conn
.prepare("SELECT host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 0")
.unwrap();
let rows: Vec<(String, Vec<u8>)> = stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))
.unwrap()
.map(Result::unwrap)
.collect();
let Some(key) = chrome_decrypt::get_encryption_key(user_data_dir) else {
return (rows.len(), 0);
};
let opened = rows
.iter()
.filter(|(host, value)| chrome_decrypt::decrypt(value, host, &key).is_some())
.count();
(rows.len(), opened)
}
async fn browser_ws_url(port: u16) -> String {
let client = reqwest::Client::new();
for _ in 0..150 {
if let Ok(response) = client
.get(format!("http://127.0.0.1:{port}/json/version"))
.send()
.await
{
if let Ok(version) = response.json::<Value>().await {
if let Some(url) = version["webSocketDebuggerUrl"].as_str() {
return url.to_string();
}
}
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
panic!("the browser never opened its debugging port");
}
async fn cdp(ws_url: &str, method: &str, params: Value) -> Value {
WayfernManager::instance()
.send_cdp_command(ws_url, method, params)
.await
.unwrap_or_else(|e| panic!("{method} failed: {e}"))
}
/// Runs the browser on `user_data_dir` the way a launch does, checks the
/// cookies earlier sessions set, sets one more and stops it the way the Stop
/// button does. Returns the names of the cookies the browser had on start.
async fn browser_session(user_data_dir: &Path, new_cookie: &str) -> Vec<String> {
let port = WayfernManager::find_free_port().await.unwrap();
let mut child = tokio::process::Command::new(wayfern_executable())
.arg(format!("--remote-debugging-port={port}"))
.arg("--remote-debugging-address=127.0.0.1")
.arg(format!("--user-data-dir={}", user_data_dir.display()))
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--disable-background-mode")
.arg("--disable-component-update")
.arg("--use-mock-keychain")
.arg("--password-store=basic")
.arg("--headless=new")
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("the browser starts");
let pid = child.id().unwrap();
// Reaped as soon as it exits, or the stop below sees a zombie as running.
let reaper = tokio::spawn(async move { child.wait().await });
let ws = browser_ws_url(port).await;
let cookies = cdp(&ws, "Storage.getCookies", json!({})).await;
let names: Vec<String> = cookies["cookies"]
.as_array()
.unwrap()
.iter()
.filter(|c| {
c["domain"]
.as_str()
.is_some_and(|d| d.trim_start_matches('.') == "example.com")
})
.map(|c| c["name"].as_str().unwrap().to_string())
.collect();
let expires = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
+ 86_400;
cdp(
&ws,
"Storage.setCookies",
json!({ "cookies": [{
"name": new_cookie,
"value": format!("{new_cookie}-value"),
"domain": "example.com",
"path": "/",
"secure": true,
"expires": expires,
}]}),
)
.await;
// The cookie store commits every 30 s or at a clean shutdown, and a browser
// asked to close in its first seconds does not shut down.
tokio::time::sleep(Duration::from_secs(32)).await;
WayfernManager::instance()
.stop_browser_process(pid, Some(port))
.await;
let _ = tokio::time::timeout(Duration::from_secs(5), reaper).await;
names
}
/// The whole life of a password-protected Wayfern profile, several times over:
/// unlock, decrypt, browse, stop, re-encrypt. Each quit is sealed by two
/// callers at once, as the Stop button and the exit watcher do. Every cookie a
/// session set must be readable by the next one.
#[tokio::test(flavor = "multi_thread")]
#[serial_test::serial]
#[ignore = "needs a Wayfern build in DONUT_TEST_WAYFERN_DIR"]
async fn cookies_survive_reencryption_through_a_real_browser() {
let data = tempfile::TempDir::new().unwrap();
let ephemeral = tempfile::TempDir::new().unwrap();
let _guard = crate::app_dirs::set_test_data_dir(data.path().to_path_buf());
// Process-wide as well: re-encryption runs on a blocking-pool thread, which
// the thread-local override above does not reach.
std::env::set_var("DONUTBROWSER_DATA_DIR", data.path());
std::env::set_var("DONUTBROWSER_CACHE_DIR", data.path().join("cache"));
std::env::set_var("DONUTBROWSER_EPHEMERAL_ROOT", ephemeral.path());
let profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
name: "reencrypt".to_string(),
browser: "wayfern".to_string(),
release_type: "stable".to_string(),
..Default::default()
};
ProfileManager::instance().save_profile(&profile).unwrap();
set_profile_password(profile.id.to_string(), PASSWORD.into())
.await
.unwrap();
let profile = load_profile(&profile.id).unwrap();
let mut expected: Vec<String> = Vec::new();
for session in 0..5 {
if !has_cached_key(&profile.id) {
unlock_profile(profile.id.to_string(), PASSWORD.into())
.await
.unwrap();
}
let dir = prepare_for_launch(&profile).unwrap();
if session == 0 {
// Bulk like a used profile has, so one seal is still running when the
// other starts.
let bulk = dir.join("Default").join("IndexedDB").join("bulk");
std::fs::create_dir_all(&bulk).unwrap();
for i in 0..400 {
std::fs::write(bulk.join(format!("{i:04}.ldb")), vec![7u8; 128 * 1024]).unwrap();
}
}
let (rows, opened) = decryptable_cookies(&dir);
assert_eq!(
rows, opened,
"session {session} found undecryptable cookies"
);
let name = format!("c{session}");
let mut seen = browser_session(&dir, &name).await;
seen.sort();
assert_eq!(seen, expected, "session {session} lost cookies");
expected.push(name);
let (watcher, stop_button) = tokio::join!(
complete_after_quit_and_wait(&profile),
complete_after_quit_and_wait(&profile)
);
assert!(
watcher.is_some() != stop_button.is_some(),
"session {session} must be sealed exactly once: {watcher:?} {stop_button:?}"
);
}
std::env::remove_var("DONUTBROWSER_EPHEMERAL_ROOT");
std::env::remove_var("DONUTBROWSER_CACHE_DIR");
std::env::remove_var("DONUTBROWSER_DATA_DIR");
}
+377 -5
View File
@@ -49,8 +49,6 @@ fn parse_err_param(err: &str, key: &str) -> Option<String> {
fn fresh_test_state(id: &uuid::Uuid) {
drop_cached_key(id);
let _ = LAUNCH_SNAPSHOTS.lock().map(|mut g| g.remove(id));
let _ = POPULATED_EPHEMERAL.lock().map(|mut g| g.remove(id));
crate::ephemeral_dirs::remove_ephemeral_dir(&id.to_string());
}
@@ -58,6 +56,81 @@ fn profile_full_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf {
profiles_dir.join(profile.id.to_string()).join("profile")
}
/// Scratch data and decrypted-copy directories, seen by this thread and by
/// the threads a seal runs on. The thread-local override alone would send a
/// seal on another thread to the real data directory.
struct Scratch {
_data: TempDir,
_ephemeral: TempDir,
_guard: crate::app_dirs::TestDirGuard,
}
impl Scratch {
fn new() -> Self {
let data = TempDir::new().unwrap();
let ephemeral = TempDir::new().unwrap();
std::env::set_var("DONUTBROWSER_DATA_DIR", data.path());
std::env::set_var("DONUTBROWSER_EPHEMERAL_ROOT", ephemeral.path());
let guard = crate::app_dirs::set_test_data_dir(data.path().to_path_buf());
Self {
_data: data,
_ephemeral: ephemeral,
_guard: guard,
}
}
}
impl Drop for Scratch {
fn drop(&mut self) {
std::env::remove_var("DONUTBROWSER_EPHEMERAL_ROOT");
std::env::remove_var("DONUTBROWSER_DATA_DIR");
}
}
const PASSWORD: &str = "hunter2!";
/// A protected profile with `populate_plaintext_dir` content, unlocked.
fn protected_profile(name: &str) -> (BrowserProfile, PathBuf) {
let profile = make_profile(name);
let store = profile_full_path(&profile, &ProfileManager::instance().get_profiles_dir());
populate_plaintext_dir(&store);
ProfileManager::instance().save_profile(&profile).unwrap();
fresh_test_state(&profile.id);
tokio::runtime::Runtime::new()
.unwrap()
.block_on(set_profile_password(
profile.id.to_string(),
PASSWORD.into(),
))
.unwrap();
(load_profile(&profile.id).unwrap(), store)
}
/// Write as a browser does, which is never in the same file-time tick as the
/// decrypt before it (on NTFS a tick is about 15 ms).
fn browser_writes(path: &Path, contents: impl AsRef<[u8]>) {
std::thread::sleep(Duration::from_millis(50));
std::fs::write(path, contents).unwrap();
}
/// What the sealed copy decrypts to, by relpath.
fn sealed_files(profile: &BrowserProfile, store: &Path) -> HashMap<String, Vec<u8>> {
let key = derive_profile_key(PASSWORD, profile.encryption_salt.as_deref().unwrap()).unwrap();
let out = TempDir::new().unwrap();
let decrypted = crate::profile::encryption::decrypt_profile_dir(&key, store, out.path()).unwrap();
assert_eq!(decrypted.unreadable, 0, "the sealed copy has damaged files");
decrypted
.baseline
.keys()
.map(|relpath| {
(
relpath.clone(),
std::fs::read(out.path().join(relpath)).unwrap(),
)
})
.collect()
}
#[test]
#[serial_test::serial]
fn integration_set_password_encrypts_dir() {
@@ -376,9 +449,14 @@ fn integration_empty_profile_session_survives_restart() {
// Launch — ephemeral starts empty (only the verifier in encrypted, which is skipped)
let ephemeral = prepare_for_launch(&profile).unwrap();
assert!(
std::fs::read_dir(&ephemeral).unwrap().next().is_none(),
"ephemeral should start empty for a fresh encrypted profile"
let names: Vec<_> = std::fs::read_dir(&ephemeral)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert_eq!(
names,
[crate::profile::encryption::BASELINE_FILE_NAME],
"ephemeral should hold only its baseline for a fresh encrypted profile"
);
// Simulate the browser writing a session
@@ -622,3 +700,297 @@ fn integration_lock_drops_key() {
fresh_test_state(&profile.id);
}
#[test]
#[serial_test::serial]
fn a_quit_both_callers_seal_is_sealed_once_and_intact() {
let _scratch = Scratch::new();
let (profile, store) = protected_profile("test-race");
for round in 0..5u8 {
if !has_cached_key(&profile.id) {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(unlock_profile(profile.id.to_string(), PASSWORD.into()))
.unwrap();
}
let copy = prepare_for_launch(&profile).unwrap();
// Enough files that one seal is still running when the other starts.
std::fs::create_dir_all(copy.join("Default/IndexedDB")).unwrap();
browser_writes(&copy.join("Default/Cookies"), [round + 1; 64]);
for i in 0..300 {
let file = copy.join("Default/IndexedDB").join(format!("{i:04}.ldb"));
std::fs::write(&file, vec![round + 1; 16 * 1024]).unwrap();
}
let callers: Vec<_> = (0..2)
.map(|_| {
let store = store.clone();
std::thread::spawn(move || seal_after_quit(profile.id, &store, false))
})
.collect();
let results: Vec<Option<usize>> = callers.into_iter().map(|c| c.join().unwrap()).collect();
assert_eq!(
results.iter().filter(|r| r.is_some()).count(),
1,
"exactly one caller seals, got {results:?}"
);
assert!(crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none());
assert!(!has_cached_key(&profile.id));
let sealed = sealed_files(&profile, &store);
assert_eq!(sealed["Default/Cookies"], vec![round + 1; 64]);
for i in 0..300 {
assert_eq!(
sealed[&format!("Default/IndexedDB/{i:04}.ldb")],
vec![round + 1; 16 * 1024],
"round {round} file {i}"
);
}
}
fresh_test_state(&profile.id);
}
#[cfg(unix)]
#[test]
#[serial_test::serial]
fn a_seal_that_fails_keeps_the_copy_for_the_next_launch_to_seal() {
use std::os::unix::fs::PermissionsExt;
let _scratch = Scratch::new();
let (profile, store) = protected_profile("test-failed-seal");
let copy = prepare_for_launch(&profile).unwrap();
browser_writes(&copy.join("Default/Cookies"), b"logged-in");
std::fs::set_permissions(&store, std::fs::Permissions::from_mode(0o555)).unwrap();
let sealed = complete_after_quit_blocking(&profile, false);
std::fs::set_permissions(&store, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(sealed, None);
assert!(has_cached_key(&profile.id), "the key stays to seal later");
assert_eq!(
std::fs::read(copy.join("Default/Cookies")).unwrap(),
b"logged-in",
"the only copy of the session must survive a failed seal"
);
assert_eq!(
sealed_files(&profile, &store)["Default/Cookies"],
b"sqlite-data"
);
let relaunched = prepare_for_launch(&profile).unwrap();
assert_eq!(relaunched, copy);
assert_eq!(
std::fs::read(relaunched.join("Default/Cookies")).unwrap(),
b"logged-in"
);
assert_eq!(
sealed_files(&profile, &store)["Default/Cookies"],
b"logged-in"
);
fresh_test_state(&profile.id);
}
#[test]
#[serial_test::serial]
fn changes_from_a_quit_without_the_key_are_sealed_after_unlock() {
let _scratch = Scratch::new();
let (profile, store) = protected_profile("test-no-key");
let copy = prepare_for_launch(&profile).unwrap();
browser_writes(&copy.join("Default/Cookies"), b"logged-in");
// The app restarted while the browser ran: the copy is there, the key is not.
drop_cached_key(&profile.id);
assert_eq!(complete_after_quit_blocking(&profile, false), None);
assert_eq!(
std::fs::read(copy.join("Default/Cookies")).unwrap(),
b"logged-in"
);
tokio::runtime::Runtime::new()
.unwrap()
.block_on(unlock_profile(profile.id.to_string(), PASSWORD.into()))
.unwrap();
let relaunched = prepare_for_launch(&profile).unwrap();
assert_eq!(
std::fs::read(relaunched.join("Default/Cookies")).unwrap(),
b"logged-in"
);
assert_eq!(
sealed_files(&profile, &store)["Default/Cookies"],
b"logged-in"
);
fresh_test_state(&profile.id);
}
#[test]
#[serial_test::serial]
fn debris_of_an_interrupted_erase_is_not_launched_on() {
let _scratch = Scratch::new();
let (profile, _store) = protected_profile("test-debris");
let copy = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string()).unwrap();
std::fs::create_dir_all(copy.join("Default")).unwrap();
// A hot journal next to a freshly decrypted database would roll it back.
std::fs::write(copy.join("Default/Cookies-journal"), [0u8; 512]).unwrap();
std::fs::write(copy.join("Default/Cookies"), [0u8; 512]).unwrap();
let launched = prepare_for_launch(&profile).unwrap();
assert!(!launched.join("Default/Cookies-journal").exists());
assert_eq!(
std::fs::read(launched.join("Default/Cookies")).unwrap(),
b"sqlite-data"
);
fresh_test_state(&profile.id);
}
#[test]
#[serial_test::serial]
fn a_launch_stopped_before_its_quit_was_sealed_keeps_its_changes() {
let _scratch = Scratch::new();
let (profile, store) = protected_profile("test-relaunch");
// A launch whose browser wrote and was then killed, with no quit sealed.
let copy = prepare_for_launch(&profile).unwrap();
browser_writes(&copy.join("os_crypt_key"), [7u8; 32]);
browser_writes(&copy.join("Default/Cookies"), b"under-key-7");
// The retry reuses the copy and must not take those files as sealed.
let retry = prepare_for_launch(&profile).unwrap();
assert_eq!(retry, copy);
assert_eq!(complete_after_quit_blocking(&profile, false), Some(0));
let sealed = sealed_files(&profile, &store);
assert_eq!(sealed["os_crypt_key"], [7u8; 32]);
assert_eq!(sealed["Default/Cookies"], b"under-key-7");
fresh_test_state(&profile.id);
}
#[test]
#[serial_test::serial]
fn lock_and_password_removal_seal_a_pending_copy_first() {
let _scratch = Scratch::new();
let rt = tokio::runtime::Runtime::new().unwrap();
let (profile, store) = protected_profile("test-pending");
let copy = prepare_for_launch(&profile).unwrap();
browser_writes(&copy.join("Default/Cookies"), b"before-lock");
rt.block_on(lock_profile(profile.id.to_string())).unwrap();
assert!(!has_cached_key(&profile.id));
assert!(crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none());
assert_eq!(
sealed_files(&profile, &store)["Default/Cookies"],
b"before-lock"
);
rt.block_on(unlock_profile(profile.id.to_string(), PASSWORD.into()))
.unwrap();
let copy = prepare_for_launch(&profile).unwrap();
browser_writes(&copy.join("Default/Cookies"), b"before-removal");
rt.block_on(remove_profile_password(
profile.id.to_string(),
PASSWORD.into(),
))
.unwrap();
assert!(crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_none());
assert!(!copy.exists(), "no plaintext copy may outlive the password");
assert_eq!(
std::fs::read(store.join("Default/Cookies")).unwrap(),
b"before-removal"
);
fresh_test_state(&profile.id);
}
#[test]
#[serial_test::serial]
fn a_password_change_seals_a_pending_copy_with_the_old_key() {
let _scratch = Scratch::new();
let rt = tokio::runtime::Runtime::new().unwrap();
let (profile, store) = protected_profile("test-change-pending");
let copy = prepare_for_launch(&profile).unwrap();
browser_writes(&copy.join("Default/Cookies"), b"pending");
rt.block_on(change_profile_password(
profile.id.to_string(),
PASSWORD.into(),
"another password".into(),
))
.unwrap();
let profile = load_profile(&profile.id).unwrap();
let key = derive_profile_key(
"another password",
profile.encryption_salt.as_deref().unwrap(),
)
.unwrap();
let out = TempDir::new().unwrap();
crate::profile::encryption::decrypt_profile_dir(&key, &store, out.path()).unwrap();
assert_eq!(
std::fs::read(out.path().join("Default/Cookies")).unwrap(),
b"pending"
);
fresh_test_state(&profile.id);
}
#[test]
#[serial_test::serial]
fn the_sealed_copy_keeps_database_journals_and_drops_caches() {
let _scratch = Scratch::new();
let (profile, store) = protected_profile("test-journals");
let copy = prepare_for_launch(&profile).unwrap();
browser_writes(&copy.join("Default/Cookies-journal"), b"hot-journal");
browser_writes(&copy.join("Default/History-wal"), b"wal-frames");
browser_writes(&copy.join("Default/Secure Preferences"), b"{}");
std::fs::create_dir_all(copy.join("Default/Code Cache/js")).unwrap();
browser_writes(&copy.join("Default/Code Cache/js/index"), b"cache");
complete_after_quit_blocking(&profile, false).unwrap();
let sealed = sealed_files(&profile, &store);
assert_eq!(sealed["Default/Cookies-journal"], b"hot-journal");
assert_eq!(sealed["Default/History-wal"], b"wal-frames");
assert_eq!(sealed["Default/Secure Preferences"], b"{}");
assert!(!sealed.contains_key("Default/Code Cache/js/index"));
assert!(!sealed.contains_key(crate::profile::encryption::BASELINE_FILE_NAME));
fresh_test_state(&profile.id);
}
#[test]
#[serial_test::serial]
fn a_kept_copy_gives_way_to_a_newer_sealed_profile_from_sync() {
let _scratch = Scratch::new();
let (profile, store) = protected_profile("test-kept-sync");
let copy = prepare_for_launch(&profile).unwrap();
complete_after_quit_blocking(&profile, true).unwrap();
assert!(copy.join("Default/Cookies").exists(), "the copy is kept");
// Another device's session arrives through sync.
let key = get_cached_key(&profile.id).unwrap();
let elsewhere = TempDir::new().unwrap();
crate::profile::encryption::decrypt_profile_dir(&key, &store, elsewhere.path()).unwrap();
browser_writes(
&elsewhere.path().join("Default/Cookies"),
b"from-the-other-device",
);
crate::profile::encryption::encrypt_profile_dir(
&key,
elsewhere.path(),
&store,
crate::profile::encryption::SEALED_EXCLUDE_PATTERNS,
)
.unwrap();
let relaunched = prepare_for_launch(&profile).unwrap();
assert_eq!(
std::fs::read(relaunched.join("Default/Cookies")).unwrap(),
b"from-the-other-device"
);
complete_after_quit_blocking(&profile, false).unwrap();
assert_eq!(
sealed_files(&profile, &store)["Default/Cookies"],
b"from-the-other-device"
);
fresh_test_state(&profile.id);
}