1use std::collections::HashMap;
4use std::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_cookie_cache::{
12 get_cached_credential, normalize_cookie_cache, read_cookie_result_cache,
13 write_cookie_result_cache, NormalizedCookieCache,
14};
15use super::browser_cookie_credentials::{
16 decrypt_windows_dpapi, read_safe_storage_password, read_windows_encryption_key,
17};
18use super::browser_cookie_crypto::{
19 app_bound_cookie_error, chromium_same_site, decode_chromium_plaintext, decrypt_chromium_cookie,
20 derive_chromium_cookie_key, firefox_same_site,
21};
22use super::browser_profiles::{
23 find_cookie_database, normalize_cookie_browser, resolve_browser_profile, BrowserProfile,
24 BrowserProfileOptions,
25};
26
27const CHROME_EPOCH_OFFSET_SECONDS: i64 = 11_644_473_600;
28
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct BrowserCookie {
33 pub name: String,
35 pub value: String,
37 pub domain: String,
39 pub path: String,
41 pub expires: i64,
43 pub http_only: bool,
45 pub secure: bool,
47 pub same_site: String,
49}
50
51#[derive(Debug, Clone)]
53pub struct BrowserCookieReadOptions {
54 pub browser: String,
56 pub profile: Option<String>,
58 pub domain_filter: Option<String>,
60 pub cache: bool,
62 pub cache_dir: Option<PathBuf>,
64 pub ttl_minutes: Option<f64>,
66 pub refresh: bool,
68 pub ignore_decryption_errors: bool,
70 pub home_dir: PathBuf,
72 pub platform: String,
74}
75
76impl BrowserCookieReadOptions {
77 pub fn new(browser: impl Into<String>) -> Self {
79 Self {
80 browser: browser.into(),
81 profile: None,
82 domain_filter: None,
83 cache: true,
84 cache_dir: None,
85 ttl_minutes: None,
86 refresh: false,
87 ignore_decryption_errors: false,
88 home_dir: dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")),
89 platform: super::browser_profiles::current_platform().to_string(),
90 }
91 }
92
93 pub fn profile(mut self, profile: impl Into<String>) -> Self {
95 self.profile = Some(profile.into());
96 self
97 }
98
99 pub fn domain_filter(mut self, domain: impl Into<String>) -> Self {
101 self.domain_filter = Some(domain.into());
102 self
103 }
104
105 pub fn cache(mut self, enabled: bool) -> Self {
107 self.cache = enabled;
108 self
109 }
110
111 pub fn cache_dir(mut self, directory: impl Into<PathBuf>) -> Self {
113 self.cache_dir = Some(directory.into());
114 self
115 }
116
117 pub fn ttl_minutes(mut self, minutes: f64) -> Self {
119 self.ttl_minutes = Some(minutes);
120 self
121 }
122
123 pub fn refresh(mut self, refresh: bool) -> Self {
125 self.refresh = refresh;
126 self
127 }
128
129 pub fn ignore_decryption_errors(mut self, ignore: bool) -> Self {
131 self.ignore_decryption_errors = ignore;
132 self
133 }
134
135 pub fn home_dir(mut self, home_dir: impl Into<PathBuf>) -> Self {
137 self.home_dir = home_dir.into();
138 self
139 }
140
141 pub fn platform(mut self, platform: impl AsRef<str>) -> Self {
143 self.platform = super::browser_profiles::normalize_platform(platform.as_ref()).to_string();
144 self
145 }
146}
147
148#[derive(Debug)]
149struct ChromiumRow {
150 host: String,
151 name: String,
152 value: String,
153 encrypted_value: Vec<u8>,
154 path: String,
155 expires: i64,
156 secure: bool,
157 http_only: bool,
158 same_site: i64,
159}
160
161#[derive(Default)]
162struct OperationKeyCache {
163 attempts: HashMap<String, std::result::Result<Vec<u8>, String>>,
164}
165
166struct CookieDecryptionState<'a> {
167 cache: &'a NormalizedCookieCache,
168 operation_keys: OperationKeyCache,
169}
170
171impl OperationKeyCache {
172 fn get_or_try_create<F>(&mut self, identity: &str, create: F) -> Result<Vec<u8>>
173 where
174 F: FnOnce() -> Result<Vec<u8>>,
175 {
176 if !self.attempts.contains_key(identity) {
177 self.attempts.insert(
178 identity.to_string(),
179 create().map_err(|error| error.to_string()),
180 );
181 }
182 self.attempts[identity]
183 .clone()
184 .map_err(|error| anyhow!(error))
185 }
186}
187
188fn open_cookie_database(path: &std::path::Path) -> Result<Connection> {
189 Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_ONLY)
190 .with_context(|| format!("Could not open browser cookie database: {}", path.display()))
191}
192
193fn read_database_version(database: &Connection) -> i64 {
194 database
195 .query_row("SELECT value FROM meta WHERE key = 'version'", [], |row| {
196 row.get::<_, String>(0)
197 })
198 .ok()
199 .and_then(|value| value.parse().ok())
200 .unwrap_or_default()
201}
202
203fn domain_pattern(domain_filter: Option<&str>) -> Option<String> {
204 domain_filter.map(|domain| format!("%{domain}%"))
205}
206
207fn read_chromium_rows(
208 database: &Connection,
209 domain_filter: Option<&str>,
210) -> Result<Vec<ChromiumRow>> {
211 let where_clause = domain_filter
212 .map(|_| " WHERE host_key LIKE ?1")
213 .unwrap_or("");
214 let query = format!(
215 "SELECT host_key, name, value, encrypted_value, path, expires_utc, \
216 is_secure, is_httponly, samesite FROM cookies{where_clause} \
217 ORDER BY host_key, name, path"
218 );
219 let mut statement = database.prepare(&query)?;
220 let pattern = domain_pattern(domain_filter);
221 let mapper = |row: &rusqlite::Row<'_>| {
222 Ok(ChromiumRow {
223 host: row.get(0)?,
224 name: row.get(1)?,
225 value: row.get(2)?,
226 encrypted_value: row.get(3)?,
227 path: row.get(4)?,
228 expires: row.get(5)?,
229 secure: row.get::<_, i64>(6)? != 0,
230 http_only: row.get::<_, i64>(7)? != 0,
231 same_site: row.get(8)?,
232 })
233 };
234 let rows = if let Some(pattern) = pattern.as_deref() {
235 statement.query_map(params![pattern], mapper)?
236 } else {
237 statement.query_map([], mapper)?
238 };
239 rows.collect::<rusqlite::Result<Vec<_>>>()
240 .map_err(Into::into)
241}
242
243pub(crate) fn read_firefox_cookies(
244 database: &Connection,
245 domain_filter: Option<&str>,
246) -> Result<Vec<BrowserCookie>> {
247 let where_clause = domain_filter.map(|_| " WHERE host LIKE ?1").unwrap_or("");
248 let query = format!(
249 "SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite \
250 FROM moz_cookies{where_clause} ORDER BY host, name, path"
251 );
252 let mut statement = database.prepare(&query)?;
253 let pattern = domain_pattern(domain_filter);
254 let mapper = |row: &rusqlite::Row<'_>| {
255 let expires = row.get::<_, i64>(4)?;
256 Ok(BrowserCookie {
257 name: row.get(0)?,
258 value: row.get(1)?,
259 domain: row.get(2)?,
260 path: row.get::<_, String>(3).map(|path| {
261 if path.is_empty() {
262 "/".to_string()
263 } else {
264 path
265 }
266 })?,
267 expires: if expires > 0 { expires } else { -1 },
268 secure: row.get::<_, i64>(5)? != 0,
269 http_only: row.get::<_, i64>(6)? != 0,
270 same_site: firefox_same_site(row.get(7)?).to_string(),
271 })
272 };
273 let rows = if let Some(pattern) = pattern.as_deref() {
274 statement.query_map(params![pattern], mapper)?
275 } else {
276 statement.query_map([], mapper)?
277 };
278 rows.collect::<rusqlite::Result<Vec<_>>>()
279 .map_err(Into::into)
280}
281
282fn credential_metadata(browser: &str, platform: &str, source: &str) -> Map<String, Value> {
283 let mut metadata = Map::new();
284 metadata.insert("browser".into(), json!(browser));
285 metadata.insert("platform".into(), json!(platform));
286 metadata.insert("source".into(), json!(source));
287 metadata
288}
289
290fn chromium_key_for_prefix(
291 prefix: &[u8],
292 browser: &str,
293 platform: &str,
294 profile: &BrowserProfile,
295 refresh: bool,
296 state: &mut CookieDecryptionState<'_>,
297) -> Result<Vec<u8>> {
298 if platform == "linux" && prefix == b"v10" {
299 return derive_chromium_cookie_key("peanuts", "linux");
302 }
303 if platform == "linux" || platform == "darwin" {
304 let identity = format!("{browser}:{platform}:safe-storage");
305 return state.operation_keys.get_or_try_create(&identity, || {
306 get_cached_credential(
307 state.cache,
308 &identity,
309 refresh,
310 credential_metadata(browser, platform, "safe-storage"),
311 || {
312 derive_chromium_cookie_key(
313 &read_safe_storage_password(browser, platform)?,
314 platform,
315 )
316 },
317 )
318 });
319 }
320 if platform == "win32" {
321 let identity = format!("{browser}:win32:legacy-aes-key");
322 return state.operation_keys.get_or_try_create(&identity, || {
323 get_cached_credential(
324 state.cache,
325 &identity,
326 refresh,
327 credential_metadata(browser, platform, "dpapi"),
328 || {
329 read_windows_encryption_key(
330 &profile
331 .path
332 .parent()
333 .unwrap_or(&profile.path)
334 .join("Local State"),
335 )
336 },
337 )
338 });
339 }
340 Err(anyhow!(
341 "Chromium cookie decryption is unsupported on {platform}"
342 ))
343}
344
345fn chromium_expires(value: i64) -> i64 {
346 if value == 0 {
347 -1
348 } else {
349 value / 1_000_000 - CHROME_EPOCH_OFFSET_SECONDS
350 }
351}
352
353fn decrypt_chromium_row(
354 row: ChromiumRow,
355 database_version: i64,
356 browser: &str,
357 platform: &str,
358 profile: &BrowserProfile,
359 refresh: bool,
360 state: &mut CookieDecryptionState<'_>,
361) -> Result<BrowserCookie> {
362 let value = if !row.value.is_empty() {
363 row.value
364 } else if row.encrypted_value.is_empty() {
365 String::new()
366 } else {
367 let prefix = row.encrypted_value.get(..3).unwrap_or_default();
368 if platform == "win32" && prefix != b"v10" && prefix != b"v11" {
369 if prefix == b"v20" {
370 return Err(app_bound_cookie_error());
371 }
372 decode_chromium_plaintext(
373 &decrypt_windows_dpapi(&row.encrypted_value)?,
374 &row.host,
375 database_version,
376 )?
377 } else {
378 let key = chromium_key_for_prefix(prefix, browser, platform, profile, refresh, state)?;
379 decrypt_chromium_cookie(
380 &row.encrypted_value,
381 &row.host,
382 database_version,
383 platform,
384 &key,
385 )?
386 }
387 };
388 Ok(BrowserCookie {
389 name: row.name,
390 value,
391 domain: row.host,
392 path: if row.path.is_empty() {
393 "/".into()
394 } else {
395 row.path
396 },
397 expires: chromium_expires(row.expires),
398 http_only: row.http_only,
399 secure: row.secure,
400 same_site: chromium_same_site(row.same_site).to_string(),
401 })
402}
403
404fn read_chromium_cookies(
405 database: &Connection,
406 profile: &BrowserProfile,
407 options: &BrowserCookieReadOptions,
408 cache: &NormalizedCookieCache,
409) -> Result<Vec<BrowserCookie>> {
410 let version = read_database_version(database);
411 let mut cookies = Vec::new();
412 let mut state = CookieDecryptionState {
413 cache,
414 operation_keys: OperationKeyCache::default(),
415 };
416 for row in read_chromium_rows(database, options.domain_filter.as_deref())? {
417 let name = row.name.clone();
418 let host = row.host.clone();
419 match decrypt_chromium_row(
420 row,
421 version,
422 &options.browser,
423 &options.platform,
424 profile,
425 options.refresh,
426 &mut state,
427 ) {
428 Ok(cookie) => cookies.push(cookie),
429 Err(_) if options.ignore_decryption_errors => {}
430 Err(error) => {
431 return Err(anyhow!(
432 "Could not decrypt cookie {name} for {host}: {error}"
433 ))
434 }
435 }
436 }
437 Ok(cookies)
438}
439
440pub fn read_browser_cookies(mut options: BrowserCookieReadOptions) -> Result<Vec<BrowserCookie>> {
442 options.browser = normalize_cookie_browser(&options.browser)?.to_string();
443 let discovery = BrowserProfileOptions::default()
444 .browser(&options.browser)
445 .home_dir(&options.home_dir)
446 .platform(&options.platform);
447 let profile =
448 resolve_browser_profile(&options.browser, options.profile.as_deref(), &discovery)?;
449 let cookie_path = find_cookie_database(&options.browser, &profile.path)
450 .ok_or_else(|| anyhow!("No cookie database exists in {}", profile.path.display()))?;
451 let cache = normalize_cookie_cache(
452 options.cache,
453 options.cache_dir.as_deref(),
454 &options.home_dir,
455 options.ttl_minutes,
456 )?;
457 let identity = serde_json::to_string(&json!({
458 "browser": options.browser,
459 "profile": profile.path,
460 "domainFilter": options.domain_filter,
461 "ignoreDecryptionErrors": options.ignore_decryption_errors,
462 }))?;
463 if let Some(values) = read_cookie_result_cache(&cache, &identity, options.refresh) {
464 return values
465 .into_iter()
466 .map(serde_json::from_value)
467 .collect::<serde_json::Result<Vec<_>>>()
468 .context("cached cookies have an invalid shape");
469 }
470
471 let database = open_cookie_database(&cookie_path)?;
472 let cookies = if options.browser == "firefox" {
473 read_firefox_cookies(&database, options.domain_filter.as_deref())?
474 } else {
475 read_chromium_cookies(&database, &profile, &options, &cache)?
476 };
477 let serialized = cookies
478 .iter()
479 .map(serde_json::to_value)
480 .collect::<serde_json::Result<Vec<_>>>()?;
481 write_cookie_result_cache(&cache, &identity, &serialized)?;
482 Ok(cookies)
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
488
489 #[test]
490 fn operation_key_cache_reads_a_refreshed_credential_once() -> Result<()> {
491 let mut keys = OperationKeyCache::default();
492 let mut calls = 0;
493 assert_eq!(
494 keys.get_or_try_create("safe-storage", || {
495 calls += 1;
496 Ok(vec![7_u8; 16])
497 })?,
498 vec![7_u8; 16]
499 );
500 assert_eq!(
501 keys.get_or_try_create("safe-storage", || {
502 calls += 1;
503 Ok(vec![8_u8; 16])
504 })?,
505 vec![7_u8; 16]
506 );
507 assert_eq!(calls, 1);
508 Ok(())
509 }
510}