mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 22:41:22 +02:00
refactor: cleanup
This commit is contained in:
1 parent
88dde52c25
commit
49690a85be
9 files changed
+1393
-231
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
|
||||
@@ -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 ----------
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
@@ -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(©.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(©.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(©.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(©.join("os_crypt_key"), [7u8; 32]);
|
||||
browser_writes(©.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(©.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(©.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(©.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(©.join("Default/Cookies-journal"), b"hot-journal");
|
||||
browser_writes(©.join("Default/History-wal"), b"wal-frames");
|
||||
browser_writes(©.join("Default/Secure Preferences"), b"{}");
|
||||
std::fs::create_dir_all(copy.join("Default/Code Cache/js")).unwrap();
|
||||
browser_writes(©.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);
|
||||
}
|
||||
Reference in new issue
Block a user