mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-09-17 23:22:21 +02:00
feat: prevent launch with inconsistent geodata
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user