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 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
243fn 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");
300 }
301 if platform == "linux" || platform == "darwin" {
302 let identity = format!("{browser}:{platform}:safe-storage");
303 return state.operation_keys.get_or_try_create(&identity, || {
304 get_cached_credential(
305 state.cache,
306 &identity,
307 refresh,
308 credential_metadata(browser, platform, "safe-storage"),
309 || {
310 derive_chromium_cookie_key(
311 &read_safe_storage_password(browser, platform)?,
312 platform,
313 )
314 },
315 )
316 });
317 }
318 if platform == "win32" {
319 let identity = format!("{browser}:win32:legacy-aes-key");
320 return state.operation_keys.get_or_try_create(&identity, || {
321 get_cached_credential(
322 state.cache,
323 &identity,
324 refresh,
325 credential_metadata(browser, platform, "dpapi"),
326 || {
327 read_windows_encryption_key(
328 &profile
329 .path
330 .parent()
331 .unwrap_or(&profile.path)
332 .join("Local State"),
333 )
334 },
335 )
336 });
337 }
338 Err(anyhow!(
339 "Chromium cookie decryption is unsupported on {platform}"
340 ))
341}
342
343fn chromium_expires(value: i64) -> i64 {
344 if value == 0 {
345 -1
346 } else {
347 value / 1_000_000 - CHROME_EPOCH_OFFSET_SECONDS
348 }
349}
350
351fn decrypt_chromium_row(
352 row: ChromiumRow,
353 database_version: i64,
354 browser: &str,
355 platform: &str,
356 profile: &BrowserProfile,
357 refresh: bool,
358 state: &mut CookieDecryptionState<'_>,
359) -> Result<BrowserCookie> {
360 let value = if !row.value.is_empty() {
361 row.value
362 } else if row.encrypted_value.is_empty() {
363 String::new()
364 } else {
365 let prefix = row.encrypted_value.get(..3).unwrap_or_default();
366 if platform == "win32" && prefix != b"v10" && prefix != b"v11" {
367 if prefix == b"v20" {
368 decrypt_chromium_cookie(
369 &row.encrypted_value,
370 &row.host,
371 database_version,
372 platform,
373 &[0_u8; 32],
374 )?
375 } else {
376 decode_chromium_plaintext(
377 &decrypt_windows_dpapi(&row.encrypted_value)?,
378 &row.host,
379 database_version,
380 )?
381 }
382 } else {
383 let key = chromium_key_for_prefix(prefix, browser, platform, profile, refresh, state)?;
384 decrypt_chromium_cookie(
385 &row.encrypted_value,
386 &row.host,
387 database_version,
388 platform,
389 &key,
390 )?
391 }
392 };
393 Ok(BrowserCookie {
394 name: row.name,
395 value,
396 domain: row.host,
397 path: if row.path.is_empty() {
398 "/".into()
399 } else {
400 row.path
401 },
402 expires: chromium_expires(row.expires),
403 http_only: row.http_only,
404 secure: row.secure,
405 same_site: chromium_same_site(row.same_site).to_string(),
406 })
407}
408
409fn read_chromium_cookies(
410 database: &Connection,
411 profile: &BrowserProfile,
412 options: &BrowserCookieReadOptions,
413 cache: &NormalizedCookieCache,
414) -> Result<Vec<BrowserCookie>> {
415 let version = read_database_version(database);
416 let mut cookies = Vec::new();
417 let mut state = CookieDecryptionState {
418 cache,
419 operation_keys: OperationKeyCache::default(),
420 };
421 for row in read_chromium_rows(database, options.domain_filter.as_deref())? {
422 let name = row.name.clone();
423 let host = row.host.clone();
424 match decrypt_chromium_row(
425 row,
426 version,
427 &options.browser,
428 &options.platform,
429 profile,
430 options.refresh,
431 &mut state,
432 ) {
433 Ok(cookie) => cookies.push(cookie),
434 Err(_) if options.ignore_decryption_errors => {}
435 Err(error) => {
436 return Err(anyhow!(
437 "Could not decrypt cookie {name} for {host}: {error}"
438 ))
439 }
440 }
441 }
442 Ok(cookies)
443}
444
445pub fn read_browser_cookies(mut options: BrowserCookieReadOptions) -> Result<Vec<BrowserCookie>> {
447 options.browser = normalize_cookie_browser(&options.browser)?.to_string();
448 let discovery = BrowserProfileOptions::default()
449 .browser(&options.browser)
450 .home_dir(&options.home_dir)
451 .platform(&options.platform);
452 let profile =
453 resolve_browser_profile(&options.browser, options.profile.as_deref(), &discovery)?;
454 let cookie_path = find_cookie_database(&options.browser, &profile.path)
455 .ok_or_else(|| anyhow!("No cookie database exists in {}", profile.path.display()))?;
456 let cache = normalize_cookie_cache(
457 options.cache,
458 options.cache_dir.as_deref(),
459 &options.home_dir,
460 options.ttl_minutes,
461 )?;
462 let identity = serde_json::to_string(&json!({
463 "browser": options.browser,
464 "profile": profile.path,
465 "domainFilter": options.domain_filter,
466 "ignoreDecryptionErrors": options.ignore_decryption_errors,
467 }))?;
468 if let Some(values) = read_cookie_result_cache(&cache, &identity, options.refresh) {
469 return values
470 .into_iter()
471 .map(serde_json::from_value)
472 .collect::<serde_json::Result<Vec<_>>>()
473 .context("cached cookies have an invalid shape");
474 }
475
476 let database = open_cookie_database(&cookie_path)?;
477 let cookies = if options.browser == "firefox" {
478 read_firefox_cookies(&database, options.domain_filter.as_deref())?
479 } else {
480 read_chromium_cookies(&database, &profile, &options, &cache)?
481 };
482 let serialized = cookies
483 .iter()
484 .map(serde_json::to_value)
485 .collect::<serde_json::Result<Vec<_>>>()?;
486 write_cookie_result_cache(&cache, &identity, &serialized)?;
487 Ok(cookies)
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn operation_key_cache_reads_a_refreshed_credential_once() -> Result<()> {
496 let mut keys = OperationKeyCache::default();
497 let mut calls = 0;
498 assert_eq!(
499 keys.get_or_try_create("safe-storage", || {
500 calls += 1;
501 Ok(vec![7_u8; 16])
502 })?,
503 vec![7_u8; 16]
504 );
505 assert_eq!(
506 keys.get_or_try_create("safe-storage", || {
507 calls += 1;
508 Ok(vec![8_u8; 16])
509 })?,
510 vec![7_u8; 16]
511 );
512 assert_eq!(calls, 1);
513 Ok(())
514 }
515}