Files
donutbrowser/src-tauri/src/launch_gate_prefs.rs
T

281 lines
8.9 KiB
Rust

//! Persisted "I know, launch it anyway" acknowledgements for the launch gate.
//!
//! Deliberately NOT synced. An acknowledgement is a statement about this
//! machine's operator ("I understand this profile's exit disagrees with its
//! fingerprint"), not a property of the profile. Syncing it would let one
//! teammate disarm another's gate, and writing it into profile metadata would
//! bump `updated_at` and make a local dismissal look like a remote edit.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use crate::profile::types::BrowserProfile;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FingerprintAck {
/// Hash of the fingerprint that was acknowledged.
pub fingerprint_hash: String,
/// Exit endpoint identity it was acknowledged against.
pub exit_identity: String,
pub acked_at: u64,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct LaunchGatePrefs {
#[serde(default)]
pub fingerprint_acks: HashMap<String, FingerprintAck>,
/// Profile id -> acknowledged extension keys.
#[serde(default)]
pub vpn_extension_acks: HashMap<String, Vec<String>>,
}
lazy_static::lazy_static! {
/// Serializes read-modify-write so two concurrent acknowledgements in a bulk
/// run cannot clobber each other.
static ref PREFS_LOCK: Mutex<()> = Mutex::new(());
}
fn prefs_file() -> PathBuf {
crate::app_dirs::data_subdir().join("launch_gate_prefs.json")
}
pub fn load() -> LaunchGatePrefs {
let Ok(content) = std::fs::read_to_string(prefs_file()) else {
return LaunchGatePrefs::default();
};
serde_json::from_str(&content).unwrap_or_else(|e| {
log::warn!("Failed to parse launch gate prefs, ignoring them: {e}");
LaunchGatePrefs::default()
})
}
fn save(prefs: &LaunchGatePrefs) {
let path = prefs_file();
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
log::warn!("Failed to create launch gate prefs dir: {e}");
return;
}
}
match serde_json::to_string_pretty(prefs) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
log::warn!("Failed to write launch gate prefs: {e}");
}
}
Err(e) => log::warn!("Failed to serialize launch gate prefs: {e}"),
}
}
fn update(mutate: impl FnOnce(&mut LaunchGatePrefs)) {
let _guard = PREFS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let mut prefs = load();
mutate(&mut prefs);
save(&prefs);
}
/// Stable digest of a profile's stored fingerprint, so an acknowledgement stops
/// applying the moment the fingerprint is regenerated or matched to a new exit.
pub fn fingerprint_hash(profile: &BrowserProfile) -> String {
use sha2::{Digest, Sha256};
let fingerprint = profile
.wayfern_config
.as_ref()
.and_then(|c| c.fingerprint.as_deref())
.unwrap_or("");
let mut hasher = Sha256::new();
hasher.update(fingerprint.as_bytes());
hasher
.finalize()
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
/// Record that the user accepted this exact (fingerprint, exit) mismatch.
pub fn ack_fingerprint(profile: &BrowserProfile, exit_identity: &str) {
let ack = FingerprintAck {
fingerprint_hash: fingerprint_hash(profile),
exit_identity: exit_identity.to_string(),
acked_at: crate::proxy_manager::now_secs(),
};
let profile_id = profile.id.to_string();
update(|prefs| {
prefs.fingerprint_acks.insert(profile_id, ack);
});
}
/// Whether the user already accepted the mismatch this profile currently has.
///
/// Bound to both the fingerprint and the exit endpoint on purpose: the old
/// per-profile "don't warn again" flag never expired, so one dismissal left a
/// profile unprotected forever, including after its proxy was swapped for one
/// in a different country.
pub fn fingerprint_ack_matches(profile: &BrowserProfile, exit_identity: &str) -> bool {
let prefs = load();
prefs
.fingerprint_acks
.get(&profile.id.to_string())
.is_some_and(|ack| {
ack.fingerprint_hash == fingerprint_hash(profile) && ack.exit_identity == exit_identity
})
}
pub fn ack_extensions(profile_id: &str, keys: &[String]) {
if keys.is_empty() {
return;
}
let profile_id = profile_id.to_string();
let keys = keys.to_vec();
update(|prefs| {
let entry = prefs.vpn_extension_acks.entry(profile_id).or_default();
for key in keys {
if !entry.contains(&key) {
entry.push(key);
}
}
});
}
/// True when every one of these extensions has already been acknowledged for
/// this profile. Installing a *different* VPN extension later re-warns, because
/// its key is not in the acknowledged set.
pub fn extensions_acked(profile_id: &str, keys: &[String]) -> bool {
if keys.is_empty() {
return true;
}
let prefs = load();
let Some(acked) = prefs.vpn_extension_acks.get(profile_id) else {
return false;
};
keys.iter().all(|k| acked.contains(k))
}
/// Drop everything remembered for a profile, for use when it is deleted.
pub fn forget_profile(profile_id: &str) {
let profile_id = profile_id.to_string();
update(|prefs| {
prefs.fingerprint_acks.remove(&profile_id);
prefs.vpn_extension_acks.remove(&profile_id);
});
}
#[cfg(test)]
mod tests {
use super::*;
fn profile_with(fingerprint: &str) -> BrowserProfile {
let mut profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
browser: "wayfern".into(),
..Default::default()
};
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
fingerprint: Some(fingerprint.to_string()),
..Default::default()
});
profile
}
#[test]
fn fingerprint_hash_changes_with_the_fingerprint() {
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let b = profile_with(r#"{"timezone":"America/New_York"}"#);
assert_ne!(fingerprint_hash(&a), fingerprint_hash(&b));
}
#[test]
fn fingerprint_hash_is_stable_for_the_same_fingerprint() {
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let b = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
assert_eq!(fingerprint_hash(&a), fingerprint_hash(&b));
}
#[test]
fn a_profile_without_a_fingerprint_still_hashes() {
let profile = BrowserProfile {
id: uuid::Uuid::new_v4(),
..Default::default()
};
assert!(!fingerprint_hash(&profile).is_empty());
}
#[test]
fn acks_round_trip_and_rearm_on_change() {
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
ack_fingerprint(&profile, "http://gw:1");
assert!(fingerprint_ack_matches(&profile, "http://gw:1"));
// Swapping the proxy re-arms the gate: the mismatch the user accepted is
// not the mismatch they now have.
assert!(!fingerprint_ack_matches(&profile, "http://other:2"));
// Regenerating the fingerprint re-arms it too.
let regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
let mut same_id = regenerated.clone();
same_id.id = profile.id;
assert!(!fingerprint_ack_matches(&same_id, "http://gw:1"));
}
#[test]
fn extension_acks_are_per_key() {
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
let profile_id = uuid::Uuid::new_v4().to_string();
let nord = vec!["crx:aaaa".to_string()];
let other = vec!["crx:bbbb".to_string()];
assert!(!extensions_acked(&profile_id, &nord));
ack_extensions(&profile_id, &nord);
assert!(extensions_acked(&profile_id, &nord));
// A different extension installed later must warn again.
assert!(!extensions_acked(&profile_id, &other));
assert!(!extensions_acked(
&profile_id,
&[nord[0].clone(), other[0].clone()]
));
// Nothing to acknowledge is trivially acknowledged, so an empty scan never
// opens the dialog.
assert!(extensions_acked(&profile_id, &[]));
}
#[test]
fn forgetting_a_profile_clears_both_kinds_of_ack() {
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
let profile_id = profile.id.to_string();
ack_fingerprint(&profile, "http://gw:1");
ack_extensions(&profile_id, &["crx:aaaa".to_string()]);
forget_profile(&profile_id);
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
assert!(!extensions_acked(&profile_id, &["crx:aaaa".to_string()]));
}
#[test]
fn corrupt_prefs_file_is_ignored_rather_than_fatal() {
let dir = tempfile::tempdir().expect("tempdir").keep();
let _guard = crate::app_dirs::set_test_data_dir(dir.clone());
std::fs::create_dir_all(dir.join("data")).unwrap();
std::fs::write(
dir.join("data").join("launch_gate_prefs.json"),
"{ not json",
)
.unwrap();
// Must not panic, and must fail closed (nothing acknowledged).
let prefs = load();
assert!(prefs.fingerprint_acks.is_empty());
}
}