1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use anyhow::{anyhow, Context, Result};
7use rusqlite::{params, Connection, OpenFlags};
8use serde::{Deserialize, Serialize};
9use serde_json::{json, Map, Value};
10
11use super::browser_sources::{browser_family, Environment};
12use super::default_browser::RunCommand;
13
14use super::browser_cookie_cache::{
15 get_cached_credential, normalize_cookie_cache, read_cookie_result_cache,
16 write_cookie_result_cache, NormalizedCookieCache,
17};
18use super::browser_cookie_credentials::{
19 decrypt_windows_dpapi, read_safe_storage_password, read_windows_encryption_key,
20};
21use super::browser_cookie_crypto::{
22 app_bound_cookie_error, chromium_same_site, decode_chromium_plaintext, decrypt_chromium_cookie,
23 derive_chromium_cookie_key, firefox_same_site,
24};
25use super::browser_profiles::{
26 find_cookie_database, resolve_browser_profile, resolve_source_browser, BrowserProfileOptions,
27};
28
29const CHROME_EPOCH_OFFSET_SECONDS: i64 = 11_644_473_600;
30
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "camelCase")]
34pub struct BrowserCookie {
35 pub name: String,
37 pub value: String,
39 pub domain: String,
41 pub path: String,
43 pub expires: i64,
45 pub http_only: bool,
47 pub secure: bool,
49 pub same_site: String,
51}
52
53#[derive(Clone)]
55pub struct BrowserCookieReadOptions {
56 pub browser: String,
58 pub profile: Option<String>,
60 pub profile_dir: Option<PathBuf>,
64 pub domain_filter: Option<String>,
66 pub cache: bool,
68 pub cache_dir: Option<PathBuf>,
70 pub ttl_minutes: Option<f64>,
72 pub refresh: bool,
74 pub ignore_decryption_errors: bool,
76 pub home_dir: PathBuf,
78 pub platform: String,
80 pub environment: Environment,
82 pub run_command: Option<RunCommand>,
85}
86
87impl std::fmt::Debug for BrowserCookieReadOptions {
88 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89 formatter
90 .debug_struct("BrowserCookieReadOptions")
91 .field("browser", &self.browser)
92 .field("profile", &self.profile)
93 .field("profile_dir", &self.profile_dir)
94 .field("domain_filter", &self.domain_filter)
95 .field("cache", &self.cache)
96 .field("cache_dir", &self.cache_dir)
97 .field("ttl_minutes", &self.ttl_minutes)
98 .field("refresh", &self.refresh)
99 .field("ignore_decryption_errors", &self.ignore_decryption_errors)
100 .field("home_dir", &self.home_dir)
101 .field("platform", &self.platform)
102 .field("environment", &self.environment)
103 .field("run_command", &self.run_command.as_ref().map(|_| "<fn>"))
104 .finish()
105 }
106}
107
108impl BrowserCookieReadOptions {
109 pub fn new(browser: impl Into<String>) -> Self {
111 Self {
112 browser: browser.into(),
113 profile: None,
114 profile_dir: None,
115 domain_filter: None,
116 cache: true,
117 cache_dir: None,
118 ttl_minutes: None,
119 refresh: false,
120 ignore_decryption_errors: false,
121 home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
122 platform: super::browser_profiles::current_platform().to_string(),
123 environment: super::browser_sources::current_environment(),
124 run_command: None,
125 }
126 }
127
128 pub fn profile(mut self, profile: impl Into<String>) -> Self {
130 self.profile = Some(profile.into());
131 self
132 }
133
134 pub fn profile_dir(mut self, profile_dir: impl Into<PathBuf>) -> Self {
137 self.profile_dir = Some(profile_dir.into());
138 self
139 }
140
141 pub fn environment(mut self, environment: Environment) -> Self {
143 self.environment = environment;
144 self
145 }
146
147 pub fn run_command(mut self, run_command: RunCommand) -> Self {
149 self.run_command = Some(run_command);
150 self
151 }
152
153 pub fn domain_filter(mut self, domain: impl Into<String>) -> Self {
155 self.domain_filter = Some(domain.into());
156 self
157 }
158
159 pub fn cache(mut self, enabled: bool) -> Self {
161 self.cache = enabled;
162 self
163 }
164
165 pub fn cache_dir(mut self, directory: impl Into<PathBuf>) -> Self {
167 self.cache_dir = Some(directory.into());
168 self
169 }
170
171 pub fn ttl_minutes(mut self, minutes: f64) -> Self {
173 self.ttl_minutes = Some(minutes);
174 self
175 }
176
177 pub fn refresh(mut self, refresh: bool) -> Self {
179 self.refresh = refresh;
180 self
181 }
182
183 pub fn ignore_decryption_errors(mut self, ignore: bool) -> Self {
185 self.ignore_decryption_errors = ignore;
186 self
187 }
188
189 pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
191 self.home_dir = home_dir.into();
192 self
193 }
194
195 pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
197 self.platform = super::browser_profiles::normalize_platform(platform.as_ref()).to_string();
198 self
199 }
200}
201
202#[derive(Debug)]
203struct ChromiumRow {
204 host: String,
205 name: String,
206 value: String,
207 encrypted_value: Vec<u8>,
208 path: String,
209 expires: i64,
210 secure: bool,
211 http_only: bool,
212 same_site: i64,
213}
214
215#[derive(Default)]
216struct OperationKeyCache {
217 attempts: HashMap<String, std::result::Result<Vec<u8>, String>>,
218}
219
220struct CookieDecryptionState<'a> {
221 cache: &'a NormalizedCookieCache,
222 operation_keys: OperationKeyCache,
223}
224
225impl OperationKeyCache {
226 fn get_or_try_create<F>(&mut self, identity: &str, create: F) -> Result<Vec<u8>>
227 where
228 F: FnOnce() -> Result<Vec<u8>>,
229 {
230 if !self.attempts.contains_key(identity) {
231 self.attempts.insert(
232 identity.to_string(),
233 create().map_err(|error| error.to_string()),
234 );
235 }
236 self.attempts[identity]
237 .clone()
238 .map_err(|error| anyhow!(error))
239 }
240}
241
242pub(crate) fn open_cookie_database(path: &std::path::Path) -> Result<Connection> {
243 Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
244 .with_context(|| format!("Could not open browser cookie database: {}", path.display()))
245}
246
247fn read_database_version(database: &Connection) -> i64 {
248 database
249 .query_row("SELECT value FROM meta WHERE key = 'version'", [], |row| {
250 row.get::<_, String>(0)
251 })
252 .ok()
253 .and_then(|value| value.parse().ok())
254 .unwrap_or_default()
255}
256
257fn domain_pattern(domain_filter: Option<&str>) -> Option<String> {
258 domain_filter.map(|domain| format!("%{domain}%"))
259}
260
261fn read_chromium_rows(
262 database: &Connection,
263 domain_filter: Option<&str>,
264) -> Result<Vec<ChromiumRow>> {
265 let where_clause = domain_filter
266 .map(|_| " WHERE host_key LIKE ?1")
267 .unwrap_or("");
268 let query = format!(
269 "SELECT host_key, name, value, encrypted_value, path, expires_utc, \
270 is_secure, is_httponly, samesite FROM cookies{where_clause} \
271 ORDER BY host_key, name, path"
272 );
273 let mut statement = database.prepare(&query)?;
274 let pattern = domain_pattern(domain_filter);
275 let mapper = |row: &rusqlite::Row<'_>| {
276 Ok(ChromiumRow {
277 host: row.get(0)?,
278 name: row.get(1)?,
279 value: row.get(2)?,
280 encrypted_value: row.get(3)?,
281 path: row.get(4)?,
282 expires: row.get(5)?,
283 secure: row.get::<_, i64>(6)? != 0,
284 http_only: row.get::<_, i64>(7)? != 0,
285 same_site: row.get(8)?,
286 })
287 };
288 let rows = if let Some(pattern) = pattern.as_deref() {
289 statement.query_map(params![pattern], mapper)?
290 } else {
291 statement.query_map([], mapper)?
292 };
293 rows.collect::<rusqlite::Result<Vec<_>>>()
294 .map_err(Into::into)
295}
296
297pub(crate) fn read_firefox_cookies(
298 database: &Connection,
299 domain_filter: Option<&str>,
300) -> Result<Vec<BrowserCookie>> {
301 let version = database.pragma_query_value(None, "user_version", |row| row.get::<_, i64>(0))?;
304 let expiry = if version >= 16 {
305 "expiry / 1000 AS expiry"
306 } else {
307 "expiry"
308 };
309 let where_clause = domain_filter.map(|_| " WHERE host LIKE ?1").unwrap_or("");
310 let query = format!(
311 "SELECT name, value, host, path, {expiry}, isSecure, isHttpOnly, sameSite \
312 FROM moz_cookies{where_clause} ORDER BY host, name, path"
313 );
314 let mut statement = database.prepare(&query)?;
315 let pattern = domain_pattern(domain_filter);
316 let mapper = |row: &rusqlite::Row<'_>| {
317 let expires = row.get::<_, i64>(4)?;
318 Ok(BrowserCookie {
319 name: row.get(0)?,
320 value: row.get(1)?,
321 domain: row.get(2)?,
322 path: row.get::<_, String>(3).map(|path| {
323 if path.is_empty() {
324 "/".to_string()
325 } else {
326 path
327 }
328 })?,
329 expires: if expires > 0 { expires } else { -1 },
330 secure: row.get::<_, i64>(5)? != 0,
331 http_only: row.get::<_, i64>(6)? != 0,
332 same_site: firefox_same_site(row.get(7)?).to_string(),
333 })
334 };
335 let rows = if let Some(pattern) = pattern.as_deref() {
336 statement.query_map(params![pattern], mapper)?
337 } else {
338 statement.query_map([], mapper)?
339 };
340 rows.collect::<rusqlite::Result<Vec<_>>>()
341 .map_err(Into::into)
342}
343
344fn credential_metadata(browser: &str, platform: &str, source: &str) -> Map<String, Value> {
345 let mut metadata = Map::new();
346 metadata.insert("browser".into(), json!(browser));
347 metadata.insert("platform".into(), json!(platform));
348 metadata.insert("source".into(), json!(source));
349 metadata
350}
351
352fn chromium_key_for_prefix(
353 prefix: &[u8],
354 browser: &str,
355 platform: &str,
356 profile_path: &Path,
357 refresh: bool,
358 state: &mut CookieDecryptionState<'_>,
359) -> Result<Vec<u8>> {
360 if platform == "linux" && prefix == b"v10" {
361 return derive_chromium_cookie_key("peanuts", "linux");
364 }
365 if platform == "linux" || platform == "darwin" {
366 let identity = format!("{browser}:{platform}:safe-storage");
367 return state.operation_keys.get_or_try_create(&identity, || {
368 get_cached_credential(
369 state.cache,
370 &identity,
371 refresh,
372 credential_metadata(browser, platform, "safe-storage"),
373 || {
374 derive_chromium_cookie_key(
375 &read_safe_storage_password(browser, platform)?,
376 platform,
377 )
378 },
379 )
380 });
381 }
382 if platform == "win32" {
383 let identity = format!("{browser}:win32:legacy-aes-key");
384 return state.operation_keys.get_or_try_create(&identity, || {
385 get_cached_credential(
386 state.cache,
387 &identity,
388 refresh,
389 credential_metadata(browser, platform, "dpapi"),
390 || {
391 read_windows_encryption_key(
392 &super::browser_profile_files::local_state_path_for_profile(profile_path),
393 )
394 },
395 )
396 });
397 }
398 Err(anyhow!(
399 "Chromium cookie decryption is unsupported on {platform}"
400 ))
401}
402
403fn chromium_expires(value: i64) -> i64 {
404 if value == 0 {
405 -1
406 } else {
407 value / 1_000_000 - CHROME_EPOCH_OFFSET_SECONDS
408 }
409}
410
411fn decrypt_chromium_row(
412 row: ChromiumRow,
413 database_version: i64,
414 browser: &str,
415 platform: &str,
416 profile_path: &Path,
417 refresh: bool,
418 state: &mut CookieDecryptionState<'_>,
419) -> Result<BrowserCookie> {
420 let value = if !row.value.is_empty() {
421 row.value
422 } else if row.encrypted_value.is_empty() {
423 String::new()
424 } else {
425 let prefix = row.encrypted_value.get(..3).unwrap_or_default();
426 if platform == "win32" && prefix != b"v10" && prefix != b"v11" {
427 if prefix == b"v20" {
428 return Err(app_bound_cookie_error());
429 }
430 decode_chromium_plaintext(
431 &decrypt_windows_dpapi(&row.encrypted_value)?,
432 &row.host,
433 database_version,
434 )?
435 } else {
436 let key =
437 chromium_key_for_prefix(prefix, browser, platform, profile_path, refresh, state)?;
438 decrypt_chromium_cookie(
439 &row.encrypted_value,
440 &row.host,
441 database_version,
442 platform,
443 &key,
444 )?
445 }
446 };
447 Ok(BrowserCookie {
448 name: row.name,
449 value,
450 domain: row.host,
451 path: if row.path.is_empty() {
452 "/".into()
453 } else {
454 row.path
455 },
456 expires: chromium_expires(row.expires),
457 http_only: row.http_only,
458 secure: row.secure,
459 same_site: chromium_same_site(row.same_site).to_string(),
460 })
461}
462
463fn read_chromium_cookies(
464 database: &Connection,
465 profile_path: &Path,
466 options: &BrowserCookieReadOptions,
467 cache: &NormalizedCookieCache,
468) -> Result<Vec<BrowserCookie>> {
469 let version = read_database_version(database);
470 let mut cookies = Vec::new();
471 let mut state = CookieDecryptionState {
472 cache,
473 operation_keys: OperationKeyCache::default(),
474 };
475 for row in read_chromium_rows(database, options.domain_filter.as_deref())? {
476 let name = row.name.clone();
477 let host = row.host.clone();
478 match decrypt_chromium_row(
479 row,
480 version,
481 &options.browser,
482 &options.platform,
483 profile_path,
484 options.refresh,
485 &mut state,
486 ) {
487 Ok(cookie) => cookies.push(cookie),
488 Err(_) if options.ignore_decryption_errors => {}
489 Err(error) => {
490 return Err(anyhow!(
491 "Could not decrypt cookie {name} for {host}: {error}"
492 ))
493 }
494 }
495 }
496 Ok(cookies)
497}
498
499pub fn read_browser_cookies(mut options: BrowserCookieReadOptions) -> Result<Vec<BrowserCookie>> {
501 let browser = resolve_source_browser(
502 &options.browser,
503 &options.platform,
504 &options.environment,
505 options.run_command.as_ref(),
506 )?;
507 options.browser = browser.to_string();
508 let profile_path = match &options.profile_dir {
512 Some(directory) => directory.clone(),
513 None => {
514 let mut discovery = BrowserProfileOptions::default()
515 .browser(browser)
516 .home_dir(&options.home_dir)
517 .platform(&options.platform)
518 .environment(options.environment.clone());
519 if let Some(run_command) = options.run_command.clone() {
520 discovery = discovery.run_command(run_command);
521 }
522 resolve_browser_profile(browser, options.profile.as_deref(), &discovery)?.path
523 }
524 };
525 let cookie_path = find_cookie_database(browser, &profile_path)
526 .ok_or_else(|| anyhow!("No cookie database exists in {}", profile_path.display()))?;
527 let cache = normalize_cookie_cache(
528 options.cache,
529 options.cache_dir.as_deref(),
530 &options.home_dir,
531 options.ttl_minutes,
532 )?;
533 let identity = serde_json::to_string(&json!({
534 "browser": options.browser,
535 "profile": profile_path,
536 "domainFilter": options.domain_filter,
537 "ignoreDecryptionErrors": options.ignore_decryption_errors,
538 }))?;
539 if let Some(values) = read_cookie_result_cache(&cache, &identity, options.refresh) {
540 return values
541 .into_iter()
542 .map(serde_json::from_value)
543 .collect::<serde_json::Result<Vec<_>>>()
544 .context("cached cookies have an invalid shape");
545 }
546
547 let cookies = if browser_family(browser)? == "safari" {
548 super::safari_cookies::parse_safari_cookies(
549 &super::safari_cookies::read_safari_cookie_file(&cookie_path, &options.environment)?,
550 options.domain_filter.as_deref(),
551 )?
552 } else {
553 let database = open_cookie_database(&cookie_path)?;
554 if browser_family(browser)? == "firefox" {
555 read_firefox_cookies(&database, options.domain_filter.as_deref())?
556 } else {
557 read_chromium_cookies(&database, &profile_path, &options, &cache)?
558 }
559 };
560 let serialized = cookies
561 .iter()
562 .map(serde_json::to_value)
563 .collect::<serde_json::Result<Vec<_>>>()?;
564 write_cookie_result_cache(&cache, &identity, &serialized)?;
565 Ok(cookies)
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571 use crate::browser::browser_cookie_sources::tests::{
572 write_firefox_cookies, FirefoxCookie, TempDir,
573 };
574 use std::fs;
575
576 #[test]
577 fn honours_an_explicit_profile_dir_over_the_default_profile_root() {
578 let temp = TempDir::new("bc-profiledir-");
579 let custom_profile = temp.path().join("custom").join("profile");
581 write_firefox_cookies(
582 &custom_profile,
583 &[FirefoxCookie {
584 name: "sid",
585 value: "abc",
586 host: ".example.com",
587 }],
588 );
589 let empty_home = temp.path().join("empty-home");
591 fs::create_dir_all(&empty_home).expect("empty home");
592
593 let cookies = read_browser_cookies(
594 BrowserCookieReadOptions::new("firefox")
595 .profile_dir(&custom_profile)
596 .platform("linux")
597 .home_dir(&empty_home)
598 .cache(false),
599 )
600 .unwrap();
601 assert_eq!(cookies.len(), 1);
602 assert_eq!(cookies[0].name, "sid");
603 assert_eq!(cookies[0].domain, ".example.com");
604 }
605
606 #[test]
607 fn operation_key_cache_reads_a_refreshed_credential_once() -> Result<()> {
608 let mut keys = OperationKeyCache::default();
609 let mut calls = 0;
610 assert_eq!(
611 keys.get_or_try_create("safe-storage", || {
612 calls += 1;
613 Ok(vec![7_u8; 16])
614 })?,
615 vec![7_u8; 16]
616 );
617 assert_eq!(
618 keys.get_or_try_create("safe-storage", || {
619 calls += 1;
620 Ok(vec![8_u8; 16])
621 })?,
622 vec![7_u8; 16]
623 );
624 assert_eq!(calls, 1);
625 Ok(())
626 }
627}