Skip to main content

browser_commander/browser/
browser_cookie_cache.rs

1//! Owner-only cookie and derived-key cache with cross-process locking.
2
3use std::collections::HashMap;
4use std::fs::{self, File, OpenOptions};
5use std::io::{ErrorKind, Write};
6use std::path::{Path, PathBuf};
7use std::sync::{Mutex, OnceLock};
8use std::thread;
9use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
10
11use anyhow::{anyhow, Context, Result};
12use base64::engine::general_purpose::STANDARD as BASE64;
13use base64::Engine;
14use serde_json::{json, Map, Value};
15use sha2::{Digest, Sha256};
16
17const DEFAULT_TTL_MINUTES: f64 = 60.0;
18const LOCK_STALE_SECONDS: f64 = 30.0;
19const LOCK_WAIT_SECONDS: f64 = 30.0;
20
21static CREDENTIAL_MEMORY_CACHE: OnceLock<Mutex<HashMap<String, CachedCredential>>> =
22    OnceLock::new();
23
24#[derive(Debug, Clone)]
25struct CachedCredential {
26    key: Vec<u8>,
27    saved_at: f64,
28}
29
30#[derive(Debug, Clone)]
31pub(crate) struct NormalizedCookieCache {
32    pub enabled: bool,
33    pub directory: PathBuf,
34    pub ttl_seconds: f64,
35}
36
37pub(crate) fn normalize_cookie_cache(
38    enabled: bool,
39    directory: Option<&Path>,
40    home_dir: &Path,
41    ttl_minutes: Option<f64>,
42) -> Result<NormalizedCookieCache> {
43    let ttl_minutes = ttl_minutes.unwrap_or(DEFAULT_TTL_MINUTES);
44    if !ttl_minutes.is_finite() || ttl_minutes < 0.0 {
45        return Err(anyhow!(
46            "cookie cache ttl_minutes must be a non-negative number"
47        ));
48    }
49    Ok(NormalizedCookieCache {
50        enabled,
51        directory: directory
52            .map(Path::to_path_buf)
53            .unwrap_or_else(|| home_dir.join(".browser-commander/cookie-cache")),
54        ttl_seconds: ttl_minutes * 60.0,
55    })
56}
57
58fn memory_cache() -> &'static Mutex<HashMap<String, CachedCredential>> {
59    CREDENTIAL_MEMORY_CACHE.get_or_init(|| Mutex::new(HashMap::new()))
60}
61
62/// Clear only the current process's derived-key cache.
63pub fn clear_browser_cookie_memory_cache() {
64    if let Ok(mut cache) = memory_cache().lock() {
65        cache.clear();
66    }
67}
68
69fn now_seconds() -> f64 {
70    SystemTime::now()
71        .duration_since(UNIX_EPOCH)
72        .map(|duration| duration.as_secs_f64())
73        .unwrap_or_default()
74}
75
76fn hash(identity: &str) -> String {
77    Sha256::digest(identity.as_bytes())
78        .iter()
79        .map(|byte| format!("{byte:02x}"))
80        .collect()
81}
82
83fn cache_path(cache: &NormalizedCookieCache, kind: &str, identity: &str) -> PathBuf {
84    cache
85        .directory
86        .join(format!("{kind}-{}.json", hash(identity)))
87}
88
89fn ensure_cache_directory(directory: &Path) -> Result<()> {
90    fs::create_dir_all(directory)
91        .with_context(|| format!("Could not create cookie cache {}", directory.display()))?;
92    restrict_owner_only(directory, true)?;
93    Ok(())
94}
95
96#[cfg(unix)]
97fn restrict_owner_only(path: &Path, directory: bool) -> Result<()> {
98    use std::os::unix::fs::PermissionsExt;
99
100    let mode = if directory { 0o700 } else { 0o600 };
101    fs::set_permissions(path, fs::Permissions::from_mode(mode))
102        .with_context(|| format!("Could not protect cookie cache {}", path.display()))
103}
104
105#[cfg(windows)]
106fn restrict_owner_only(path: &Path, directory: bool) -> Result<()> {
107    use std::process::Command;
108
109    let whoami = Command::new("whoami")
110        .output()
111        .context("Could not identify the current Windows user")?;
112    if !whoami.status.success() {
113        return Err(anyhow!("Could not identify the current Windows user"));
114    }
115    let principal = String::from_utf8(whoami.stdout)
116        .context("Windows user identity was not valid UTF-8")?
117        .trim()
118        .to_owned();
119    if principal.is_empty() {
120        return Err(anyhow!("Could not identify the current Windows user"));
121    }
122    let permission = if directory { "(OI)(CI)F" } else { "F" };
123    let status = Command::new("icacls")
124        .arg(path)
125        .args(["/inheritance:r", "/grant:r"])
126        .arg(format!("{principal}:{permission}"))
127        .arg("/q")
128        .status()
129        .with_context(|| format!("Could not protect cookie cache {}", path.display()))?;
130    if !status.success() {
131        return Err(anyhow!(
132            "Could not protect cookie cache {} with a Windows ACL",
133            path.display()
134        ));
135    }
136    Ok(())
137}
138
139#[cfg(all(not(unix), not(windows)))]
140fn restrict_owner_only(_path: &Path, _directory: bool) -> Result<()> {
141    Err(anyhow!(
142        "owner-only cookie caching is unsupported on this platform"
143    ))
144}
145
146fn read_fresh_json(path: &Path, ttl_seconds: f64) -> Option<Value> {
147    let value = serde_json::from_str::<Value>(&fs::read_to_string(path).ok()?).ok()?;
148    let saved_at = value.get("savedAt")?.as_f64()?;
149    let age = now_seconds() - saved_at;
150    (age >= 0.0 && age <= ttl_seconds).then_some(value)
151}
152
153fn temporary_path(path: &Path) -> PathBuf {
154    let unique = SystemTime::now()
155        .duration_since(UNIX_EPOCH)
156        .map(|duration| duration.as_nanos())
157        .unwrap_or_default();
158    path.with_file_name(format!(
159        "{}.{}.{unique}.tmp",
160        path.file_name()
161            .and_then(|name| name.to_str())
162            .unwrap_or("cache"),
163        std::process::id()
164    ))
165}
166
167fn owner_only_file(path: &Path) -> Result<File> {
168    let mut options = OpenOptions::new();
169    options.write(true).create_new(true);
170    #[cfg(unix)]
171    {
172        use std::os::unix::fs::OpenOptionsExt;
173        options.mode(0o600);
174    }
175    options
176        .open(path)
177        .with_context(|| format!("Could not create owner-only cache file {}", path.display()))
178}
179
180fn write_owner_only_json(path: &Path, value: &Value) -> Result<()> {
181    let temporary = temporary_path(path);
182    let mut file = owner_only_file(&temporary)?;
183    let result = (|| -> Result<()> {
184        serde_json::to_writer(&mut file, value)?;
185        file.write_all(b"\n")?;
186        file.sync_all()?;
187        drop(file);
188        #[cfg(windows)]
189        if path.exists() {
190            fs::remove_file(path)?;
191        }
192        fs::rename(&temporary, path)?;
193        restrict_owner_only(path, false)?;
194        Ok(())
195    })();
196    if result.is_err() {
197        let _ = fs::remove_file(&temporary);
198    }
199    result.with_context(|| format!("Could not write cookie cache {}", path.display()))
200}
201
202pub(crate) fn read_cookie_result_cache(
203    cache: &NormalizedCookieCache,
204    identity: &str,
205    refresh: bool,
206) -> Option<Vec<Value>> {
207    if !cache.enabled || refresh {
208        return None;
209    }
210    let value = read_fresh_json(&cache_path(cache, "cookies", identity), cache.ttl_seconds)?;
211    if value.get("kind").and_then(Value::as_str) != Some("cookies") {
212        return None;
213    }
214    value.get("cookies")?.as_array().cloned()
215}
216
217pub(crate) fn write_cookie_result_cache(
218    cache: &NormalizedCookieCache,
219    identity: &str,
220    cookies: &[Value],
221) -> Result<()> {
222    if !cache.enabled {
223        return Ok(());
224    }
225    ensure_cache_directory(&cache.directory)?;
226    write_owner_only_json(
227        &cache_path(cache, "cookies", identity),
228        &json!({
229            "version": 1,
230            "kind": "cookies",
231            "savedAt": now_seconds(),
232            "cookies": cookies,
233        }),
234    )
235}
236
237enum LockResult {
238    Acquired(File),
239    Cached(Value),
240}
241
242fn remove_stale_lock(lock_path: &Path) {
243    let stale = fs::metadata(lock_path)
244        .and_then(|metadata| metadata.modified())
245        .ok()
246        .and_then(|modified| SystemTime::now().duration_since(modified).ok())
247        .is_some_and(|age| age.as_secs_f64() > LOCK_STALE_SECONDS);
248    if stale {
249        let _ = fs::remove_file(lock_path);
250    }
251}
252
253fn acquire_lock_or_cached(
254    lock_path: &Path,
255    cached_path: &Path,
256    ttl_seconds: f64,
257    refresh: bool,
258    initial_saved_at: Option<f64>,
259) -> Result<LockResult> {
260    let started = Instant::now();
261    while started.elapsed().as_secs_f64() <= LOCK_WAIT_SECONDS {
262        match owner_only_file(lock_path) {
263            Ok(file) => return Ok(LockResult::Acquired(file)),
264            Err(error)
265                if error
266                    .downcast_ref::<std::io::Error>()
267                    .is_some_and(|error| error.kind() == ErrorKind::AlreadyExists) =>
268            {
269                if let Some(value) = read_fresh_json(cached_path, ttl_seconds) {
270                    let saved_at = value.get("savedAt").and_then(Value::as_f64);
271                    if value.get("kind").and_then(Value::as_str) == Some("derived-key")
272                        && (!refresh || saved_at != initial_saved_at)
273                    {
274                        return Ok(LockResult::Cached(value));
275                    }
276                }
277                remove_stale_lock(lock_path);
278                thread::sleep(Duration::from_millis(50));
279            }
280            Err(error) => return Err(error),
281        }
282    }
283    Err(anyhow!(
284        "timed out waiting for another cookie credential reader"
285    ))
286}
287
288fn decode_cached_credential(value: &Value) -> Result<CachedCredential> {
289    let key = BASE64
290        .decode(
291            value
292                .get("key")
293                .and_then(Value::as_str)
294                .ok_or_else(|| anyhow!("derived-key cache has no key"))?,
295        )
296        .context("derived-key cache contains invalid base64")?;
297    let saved_at = value
298        .get("savedAt")
299        .and_then(Value::as_f64)
300        .ok_or_else(|| anyhow!("derived-key cache has no savedAt timestamp"))?;
301    Ok(CachedCredential { key, saved_at })
302}
303
304pub(crate) fn get_cached_credential<F>(
305    cache: &NormalizedCookieCache,
306    identity: &str,
307    refresh: bool,
308    metadata: Map<String, Value>,
309    create: F,
310) -> Result<Vec<u8>>
311where
312    F: FnOnce() -> Result<Vec<u8>>,
313{
314    let memory_identity = format!("{}:{identity}", cache.directory.display());
315    if !refresh {
316        let mut memory = memory_cache()
317            .lock()
318            .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))?;
319        if let Some(cached) = memory.get(&memory_identity) {
320            let age = now_seconds() - cached.saved_at;
321            if age >= 0.0 && age <= cache.ttl_seconds {
322                return Ok(cached.key.clone());
323            }
324        }
325        memory.remove(&memory_identity);
326    }
327    let credential = load_or_create_credential(cache, identity, refresh, metadata, create)?;
328    memory_cache()
329        .lock()
330        .map_err(|_| anyhow!("cookie credential memory cache is poisoned"))?
331        .insert(memory_identity, credential.clone());
332    Ok(credential.key)
333}
334
335fn load_or_create_credential<F>(
336    cache: &NormalizedCookieCache,
337    identity: &str,
338    refresh: bool,
339    metadata: Map<String, Value>,
340    create: F,
341) -> Result<CachedCredential>
342where
343    F: FnOnce() -> Result<Vec<u8>>,
344{
345    if !cache.enabled {
346        return Ok(CachedCredential {
347            key: create()?,
348            saved_at: now_seconds(),
349        });
350    }
351    ensure_cache_directory(&cache.directory)?;
352    let cached_path = cache_path(cache, "credential", identity);
353    let lock_path = PathBuf::from(format!("{}.lock", cached_path.display()));
354    let initial = read_fresh_json(&cached_path, cache.ttl_seconds);
355    if !refresh {
356        if let Some(value) = initial
357            .as_ref()
358            .filter(|value| value.get("kind").and_then(Value::as_str) == Some("derived-key"))
359        {
360            return decode_cached_credential(value);
361        }
362    }
363    let initial_saved_at = initial
364        .as_ref()
365        .and_then(|value| value.get("savedAt"))
366        .and_then(Value::as_f64);
367    match acquire_lock_or_cached(
368        &lock_path,
369        &cached_path,
370        cache.ttl_seconds,
371        refresh,
372        initial_saved_at,
373    )? {
374        LockResult::Cached(value) => decode_cached_credential(&value),
375        LockResult::Acquired(lock) => {
376            drop(lock);
377            let result = (|| -> Result<CachedCredential> {
378                if let Some(value) = read_fresh_json(&cached_path, cache.ttl_seconds) {
379                    let saved_at = value.get("savedAt").and_then(Value::as_f64);
380                    if value.get("kind").and_then(Value::as_str) == Some("derived-key")
381                        && (!refresh || saved_at != initial_saved_at)
382                    {
383                        return decode_cached_credential(&value);
384                    }
385                }
386                let key = create()?;
387                let saved_at = now_seconds();
388                let mut value = metadata;
389                value.insert("version".into(), json!(1));
390                value.insert("kind".into(), json!("derived-key"));
391                value.insert("savedAt".into(), json!(saved_at));
392                value.insert("key".into(), json!(BASE64.encode(&key)));
393                write_owner_only_json(&cached_path, &Value::Object(value))?;
394                Ok(CachedCredential { key, saved_at })
395            })();
396            let _ = fs::remove_file(lock_path);
397            result
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    #[test]
407    fn credential_cache_is_owner_only_and_reused_after_memory_reset() -> Result<()> {
408        #[cfg(unix)]
409        use std::os::unix::fs::PermissionsExt;
410
411        let directory = std::env::temp_dir().join(format!(
412            "browser-commander-cache-test-{}",
413            std::process::id()
414        ));
415        let _ = fs::remove_dir_all(&directory);
416        let cache = NormalizedCookieCache {
417            enabled: true,
418            directory: directory.clone(),
419            ttl_seconds: 60.0,
420        };
421        let calls = std::sync::atomic::AtomicUsize::new(0);
422        let create = || {
423            calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
424            Ok(vec![7_u8; 16])
425        };
426        let metadata = Map::new();
427        get_cached_credential(
428            &cache,
429            "chrome:linux:safe-storage",
430            false,
431            metadata.clone(),
432            create,
433        )?;
434        clear_browser_cookie_memory_cache();
435        get_cached_credential(&cache, "chrome:linux:safe-storage", false, metadata, create)?;
436        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
437        let cached = fs::read_dir(&directory)?
438            .flatten()
439            .find(|entry| {
440                entry
441                    .file_name()
442                    .to_string_lossy()
443                    .starts_with("credential-")
444            })
445            .unwrap();
446        #[cfg(unix)]
447        assert_eq!(cached.metadata()?.permissions().mode() & 0o777, 0o600);
448        #[cfg(windows)]
449        {
450            use std::process::Command;
451
452            let acl = Command::new("icacls").arg(cached.path()).output()?;
453            let principal = Command::new("whoami").output()?;
454            let acl = String::from_utf8(acl.stdout)?.to_lowercase();
455            let principal = String::from_utf8(principal.stdout)?.trim().to_lowercase();
456            assert!(acl.contains(&principal));
457            assert!(!acl.contains("(i)"));
458        }
459        fs::remove_dir_all(directory)?;
460        Ok(())
461    }
462
463    #[test]
464    fn in_process_credential_cache_expires_after_ttl() -> Result<()> {
465        let directory = std::env::temp_dir().join(format!(
466            "browser-commander-memory-ttl-test-{}",
467            std::process::id()
468        ));
469        let cache = NormalizedCookieCache {
470            enabled: false,
471            directory,
472            ttl_seconds: 0.0,
473        };
474        let calls = std::sync::atomic::AtomicUsize::new(0);
475        let create = || {
476            let call = calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1;
477            Ok(vec![call as u8; 16])
478        };
479
480        clear_browser_cookie_memory_cache();
481        assert_eq!(
482            get_cached_credential(&cache, "chrome:linux:ttl-test", false, Map::new(), create)?[0],
483            1
484        );
485        thread::sleep(Duration::from_millis(5));
486        assert_eq!(
487            get_cached_credential(&cache, "chrome:linux:ttl-test", false, Map::new(), create)?[0],
488            2
489        );
490        assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 2);
491        Ok(())
492    }
493}