| 1 | """Chromium-family cookie extraction for macOS. |
| 2 | |
| 3 | Extracts cookies from Chromium-based browser SQLite databases using only |
| 4 | stdlib modules and the system openssl CLI (ships with macOS). Zero pip |
| 5 | dependencies. |
| 6 | |
| 7 | Chromium on macOS uses v10 encryption (AES-128-CBC with Keychain-stored key). |
| 8 | Every Chromium-based browser (Chrome, Brave, Edge, Vivaldi, Opera, Arc, |
| 9 | Chromium) shares the same algorithm; only the profile directory and Keychain |
| 10 | service name differ, so they all run through the same decryption core. |
| 11 | This is NOT affected by Windows App-Bound Encryption (v20). |
| 12 | """ |
| 13 | |
| 14 | import hashlib |
| 15 | import logging |
| 16 | import os |
| 17 | import shutil |
| 18 | import sqlite3 |
| 19 | import subprocess |
| 20 | import tempfile |
| 21 | from pathlib import Path |
| 22 | from typing import Optional |
| 23 | |
| 24 | logger = logging.getLogger(__name__) |
| 25 | |
| 26 | |
| 27 | def _lock_temp_cookie_copy(path: str) -> None: |
| 28 | """Restrict copied cookie DB temp files to the current user on POSIX.""" |
| 29 | if os.name == "nt": |
| 30 | return |
| 31 | Path(path).chmod(0o600) |
| 32 | |
| 33 | # Cookie DB locations on macOS |
| 34 | _APP_SUPPORT = Path.home() / "Library" / "Application Support" |
| 35 | CHROME_BASE_DIR = _APP_SUPPORT / "Google" / "Chrome" |
| 36 | # Kept for backward compatibility; resolution now goes through the profile |
| 37 | # finder (which also handles the modern Network/Cookies layout). |
| 38 | CHROME_COOKIES_DB = CHROME_BASE_DIR / "Default" / "Cookies" |
| 39 | BRAVE_BASE_DIR = _APP_SUPPORT / "BraveSoftware" / "Brave-Browser" |
| 40 | |
| 41 | # Other Chromium-based browsers, keyed by FROM_BROWSER name. Each maps to |
| 42 | # (profile base directory, macOS Keychain service name). Chrome and Brave keep |
| 43 | # their dedicated helpers below for backward compatibility; everything here is |
| 44 | # resolved generically by extract_chromium_browser_cookies_macos(). Keychain |
| 45 | # service names follow Chromium's "<Browser> Safe Storage" convention. |
| 46 | CHROMIUM_BROWSER_PROFILES: dict[str, tuple[Path, str]] = { |
| 47 | "edge": (_APP_SUPPORT / "Microsoft Edge", "Microsoft Edge Safe Storage"), |
| 48 | "vivaldi": (_APP_SUPPORT / "Vivaldi", "Vivaldi Safe Storage"), |
| 49 | "opera": (_APP_SUPPORT / "com.operasoftware.Opera", "Opera Safe Storage"), |
| 50 | "arc": (_APP_SUPPORT / "Arc" / "User Data", "Arc Safe Storage"), |
| 51 | "chromium": (_APP_SUPPORT / "Chromium", "Chromium Safe Storage"), |
| 52 | } |
| 53 | |
| 54 | # Chromium v10 encryption constants (shared by Chrome and Brave) |
| 55 | CHROME_SALT = b"saltysalt" |
| 56 | CHROME_PBKDF2_ITERATIONS = 1003 |
| 57 | CHROME_KEY_LENGTH = 16 |
| 58 | # IV is 16 space characters (0x20) |
| 59 | CHROME_IV_HEX = "20" * 16 |
| 60 | |
| 61 | |
| 62 | def _get_chromium_encryption_key(service_name: str) -> Optional[bytes]: |
| 63 | """Retrieve the encryption passphrase for a Chromium-based browser from macOS Keychain. |
| 64 | |
| 65 | Calls `security find-generic-password` which may trigger a system dialog |
| 66 | on first access. |
| 67 | |
| 68 | Returns the raw passphrase bytes, or None on failure. |
| 69 | """ |
| 70 | try: |
| 71 | result = subprocess.run( |
| 72 | ["security", "find-generic-password", "-w", "-s", service_name], |
| 73 | capture_output=True, |
| 74 | text=True, |
| 75 | timeout=10, |
| 76 | ) |
| 77 | if result.returncode != 0: |
| 78 | logger.info("%s Keychain access denied or browser not installed: %s", service_name, result.stderr.strip()) |
| 79 | return None |
| 80 | passphrase = result.stdout.strip() |
| 81 | if not passphrase: |
| 82 | logger.info("%s Keychain returned empty passphrase", service_name) |
| 83 | return None |
| 84 | return passphrase.encode("utf-8") |
| 85 | except FileNotFoundError: |
| 86 | logger.info("'security' command not found — not on macOS?") |
| 87 | return None |
| 88 | except subprocess.TimeoutExpired: |
| 89 | logger.info("%s Keychain access timed out", service_name) |
| 90 | return None |
| 91 | except Exception as e: |
| 92 | logger.info("Failed to get %s encryption key: %s", service_name, e) |
| 93 | return None |
| 94 | |
| 95 | |
| 96 | def _get_chrome_encryption_key() -> Optional[bytes]: |
| 97 | return _get_chromium_encryption_key("Chrome Safe Storage") |
| 98 | |
| 99 | |
| 100 | def _derive_aes_key(passphrase: bytes) -> bytes: |
| 101 | """Derive 16-byte AES key from Chrome's Keychain passphrase via PBKDF2.""" |
| 102 | return hashlib.pbkdf2_hmac( |
| 103 | "sha1", |
| 104 | passphrase, |
| 105 | CHROME_SALT, |
| 106 | CHROME_PBKDF2_ITERATIONS, |
| 107 | dklen=CHROME_KEY_LENGTH, |
| 108 | ) |
| 109 | |
| 110 | |
| 111 | def _decrypt_v10_value(encrypted_value: bytes, aes_key: bytes, db_version: int) -> Optional[str]: |
| 112 | """Decrypt a Chrome v10-encrypted cookie value. |
| 113 | |
| 114 | Uses system openssl CLI for AES-128-CBC decryption (zero pip deps). |
| 115 | For Chrome 130+ (db_version >= 24), strips 32-byte SHA-256 prefix after decryption. |
| 116 | |
| 117 | Returns decrypted string or None on failure. |
| 118 | """ |
| 119 | # Strip the 'v10' prefix |
| 120 | ciphertext = encrypted_value[3:] |
| 121 | if not ciphertext: |
| 122 | return None |
| 123 | |
| 124 | hex_key = aes_key.hex() |
| 125 | |
| 126 | try: |
| 127 | result = subprocess.run( |
| 128 | [ |
| 129 | "openssl", "enc", "-aes-128-cbc", "-d", |
| 130 | "-K", hex_key, |
| 131 | "-iv", CHROME_IV_HEX, |
| 132 | "-nopad", |
| 133 | ], |
| 134 | input=ciphertext, |
| 135 | capture_output=True, |
| 136 | timeout=5, |
| 137 | ) |
| 138 | if result.returncode != 0: |
| 139 | logger.debug("openssl decryption failed: %s", result.stderr.decode(errors="replace").strip()) |
| 140 | return None |
| 141 | |
| 142 | decrypted = result.stdout |
| 143 | if not decrypted: |
| 144 | return None |
| 145 | |
| 146 | # Remove PKCS7 padding |
| 147 | decrypted = _remove_pkcs7_padding(decrypted) |
| 148 | if decrypted is None: |
| 149 | return None |
| 150 | |
| 151 | # Chrome 130+ (db version >= 24): strip 32-byte SHA-256 prefix |
| 152 | if db_version >= 24 and len(decrypted) > 32: |
| 153 | decrypted = decrypted[32:] |
| 154 | |
| 155 | return decrypted.decode("utf-8", errors="replace") |
| 156 | |
| 157 | except FileNotFoundError: |
| 158 | logger.info("openssl not found — cannot decrypt Chrome cookies") |
| 159 | return None |
| 160 | except subprocess.TimeoutExpired: |
| 161 | logger.info("openssl decryption timed out") |
| 162 | return None |
| 163 | except Exception as e: |
| 164 | logger.debug("Chrome cookie decryption error: %s", e) |
| 165 | return None |
| 166 | |
| 167 | |
| 168 | def _remove_pkcs7_padding(data: bytes) -> Optional[bytes]: |
| 169 | """Remove PKCS7 padding from decrypted data. |
| 170 | |
| 171 | The last byte indicates the number of padding bytes added. |
| 172 | All padding bytes must have the same value. |
| 173 | |
| 174 | Returns unpadded data or None if padding is invalid. |
| 175 | """ |
| 176 | if not data: |
| 177 | return None |
| 178 | pad_len = data[-1] |
| 179 | if pad_len < 1 or pad_len > 16: |
| 180 | return None |
| 181 | # Verify all padding bytes match |
| 182 | if data[-pad_len:] != bytes([pad_len]) * pad_len: |
| 183 | return None |
| 184 | return data[:-pad_len] |
| 185 | |
| 186 | |
| 187 | def _get_db_version(cursor: sqlite3.Cursor) -> int: |
| 188 | """Get Chrome cookie database version from the meta table. |
| 189 | |
| 190 | Returns 0 if meta table doesn't exist or version can't be read. |
| 191 | """ |
| 192 | try: |
| 193 | cursor.execute("SELECT value FROM meta WHERE key = 'version'") |
| 194 | row = cursor.fetchone() |
| 195 | if row: |
| 196 | return int(row[0]) |
| 197 | except Exception: |
| 198 | pass |
| 199 | return 0 |
| 200 | |
| 201 | |
| 202 | def _extract_chromium_cookies_macos( |
| 203 | db_path: Path, |
| 204 | keychain_service: str, |
| 205 | domain: str, |
| 206 | cookie_names: list[str], |
| 207 | key_cache: Optional[dict[str, Optional[bytes]]] = None, |
| 208 | ) -> Optional[dict[str, str]]: |
| 209 | """Extract cookies from any Chromium-based browser on macOS. |
| 210 | |
| 211 | Copies the locked Cookies database to a temp file, reads specified cookies, |
| 212 | and decrypts v10-encrypted values using the Keychain-stored key. |
| 213 | |
| 214 | Args: |
| 215 | db_path: Path to the browser's Cookies SQLite file. |
| 216 | keychain_service: macOS Keychain service name (e.g. "Chrome Safe Storage"). |
| 217 | domain: Cookie domain to match (e.g., ".twitter.com", ".x.com"). |
| 218 | cookie_names: List of cookie names to extract. |
| 219 | |
| 220 | Returns: |
| 221 | Dict mapping cookie name to decrypted value, or None on failure. |
| 222 | Only includes cookies that were successfully found and decrypted. |
| 223 | """ |
| 224 | if not db_path.exists(): |
| 225 | logger.info("%s cookies database not found at %s", keychain_service, db_path) |
| 226 | return None |
| 227 | |
| 228 | # Copy DB to temp file (browser locks the original while running) |
| 229 | tmp_fd = None |
| 230 | tmp_path = None |
| 231 | try: |
| 232 | tmp_fd, tmp_path = tempfile.mkstemp(suffix=".sqlite") |
| 233 | # mkstemp creates the file 0600. copy2 would copy the source DB's |
| 234 | # permission bits onto the temp file before the chmod below runs, |
| 235 | # briefly exposing live cookies when the source DB is looser. |
| 236 | shutil.copyfile(str(db_path), tmp_path) |
| 237 | _lock_temp_cookie_copy(tmp_path) |
| 238 | except Exception as e: |
| 239 | logger.info("Failed to copy %s cookies database: %s", keychain_service, e) |
| 240 | if tmp_path: |
| 241 | try: |
| 242 | Path(tmp_path).unlink(missing_ok=True) |
| 243 | except Exception: |
| 244 | pass |
| 245 | return None |
| 246 | finally: |
| 247 | if tmp_fd is not None: |
| 248 | import os |
| 249 | os.close(tmp_fd) |
| 250 | |
| 251 | try: |
| 252 | conn = sqlite3.connect(tmp_path) |
| 253 | cursor = conn.cursor() |
| 254 | |
| 255 | db_version = _get_db_version(cursor) |
| 256 | logger.debug("%s cookie DB version: %d", keychain_service, db_version) |
| 257 | |
| 258 | placeholders = ",".join("?" for _ in cookie_names) |
| 259 | query = ( |
| 260 | f"SELECT name, value, encrypted_value FROM cookies " |
| 261 | f"WHERE host_key LIKE ? AND name IN ({placeholders})" |
| 262 | ) |
| 263 | params = [f"%{domain}"] + list(cookie_names) |
| 264 | cursor.execute(query, params) |
| 265 | |
| 266 | results: dict[str, str] = {} |
| 267 | aes_key = None |
| 268 | key_fetched = False |
| 269 | for name, value, encrypted_value in cursor.fetchall(): |
| 270 | if value: |
| 271 | results[name] = value |
| 272 | continue |
| 273 | |
| 274 | if encrypted_value and encrypted_value[:3] == b"v10": |
| 275 | if not key_fetched: |
| 276 | # Fetch the Keychain key lazily — only once we actually have |
| 277 | # an encrypted cookie to decrypt. This avoids a macOS |
| 278 | # Keychain prompt for browsers that don't hold the requested |
| 279 | # cookie, which matters for FROM_BROWSER=auto across several |
| 280 | # installed Chromium browsers. |
| 281 | if key_cache is not None and keychain_service in key_cache: |
| 282 | aes_key = key_cache[keychain_service] |
| 283 | else: |
| 284 | passphrase = _get_chromium_encryption_key(keychain_service) |
| 285 | aes_key = _derive_aes_key(passphrase) if passphrase else None |
| 286 | if key_cache is not None: |
| 287 | key_cache[keychain_service] = aes_key |
| 288 | key_fetched = True |
| 289 | if aes_key is None: |
| 290 | logger.debug("Skipping encrypted cookie %s — no Keychain access", name) |
| 291 | continue |
| 292 | decrypted = _decrypt_v10_value(encrypted_value, aes_key, db_version) |
| 293 | if decrypted: |
| 294 | results[name] = decrypted |
| 295 | else: |
| 296 | logger.debug("Failed to decrypt cookie %s", name) |
| 297 | elif encrypted_value: |
| 298 | logger.debug("Unknown encryption for cookie %s (prefix: %r)", name, encrypted_value[:3]) |
| 299 | |
| 300 | conn.close() |
| 301 | |
| 302 | if not results: |
| 303 | logger.info("No matching cookies found in %s for domain %s", keychain_service, domain) |
| 304 | return None |
| 305 | |
| 306 | return results |
| 307 | |
| 308 | except sqlite3.Error as e: |
| 309 | logger.info("Failed to read %s cookies database: %s", keychain_service, e) |
| 310 | return None |
| 311 | except Exception as e: |
| 312 | logger.info("Unexpected error reading %s cookies: %s", keychain_service, e) |
| 313 | return None |
| 314 | finally: |
| 315 | try: |
| 316 | Path(tmp_path).unlink(missing_ok=True) |
| 317 | except Exception: |
| 318 | pass |
| 319 | |
| 320 | |
| 321 | def extract_chrome_cookies_macos(domain: str, cookie_names: list[str]) -> Optional[dict[str, str]]: |
| 322 | """Extract cookies from Chrome on macOS. |
| 323 | |
| 324 | Resolves the cookie DB through the shared profile finder so Chrome gets the |
| 325 | same modern ``Default/Network/Cookies`` (Chromium >= 96) and legacy |
| 326 | ``Default/Cookies`` probing as the rest of the Chromium family. |
| 327 | """ |
| 328 | return _extract_chromium_cookies_any_profile( |
| 329 | CHROME_BASE_DIR, "Chrome Safe Storage", domain, cookie_names |
| 330 | ) |
| 331 | |
| 332 | |
| 333 | def _profile_cookie_db(profile_dir: Path) -> Optional[Path]: |
| 334 | """Return the Cookies DB inside a profile dir, or None. |
| 335 | |
| 336 | Prefers the modern ``Network/Cookies`` location (Chromium >= 96 moved the |
| 337 | cookie store into a per-profile ``Network/`` subdirectory) and falls back |
| 338 | to the legacy flat ``Cookies`` file. Different browsers and versions use |
| 339 | different layouts, so both are probed. |
| 340 | """ |
| 341 | for rel in ("Network/Cookies", "Cookies"): |
| 342 | candidate = profile_dir / rel |
| 343 | if candidate.exists(): |
| 344 | return candidate |
| 345 | return None |
| 346 | |
| 347 | |
| 348 | def _find_chromium_cookies_db(base_dir: Path) -> Optional[Path]: |
| 349 | """Find a Chromium-based browser's Cookies database under base_dir. |
| 350 | |
| 351 | Checks the Default profile first, then the base dir itself (Opera's flat |
| 352 | layout), then numbered "Profile N" directories by most-recently-modified. |
| 353 | Each location is probed for both the modern ``Network/Cookies`` and legacy |
| 354 | ``Cookies`` paths (see _profile_cookie_db). Chromium browsers create extra |
| 355 | profiles as "Profile 1", "Profile 2", etc. alongside Default; the most |
| 356 | recently used one is the likeliest to hold current cookies. Lexicographic |
| 357 | sort would visit "Profile 10" before "Profile 2", which can return the |
| 358 | wrong profile, so we sort by mtime. |
| 359 | |
| 360 | Kept for backward compatibility; new code should use |
| 361 | _find_all_chromium_cookies_dbs() to search across all profiles. |
| 362 | """ |
| 363 | dbs = _find_all_chromium_cookies_dbs(base_dir) |
| 364 | return dbs[0] if dbs else None |
| 365 | |
| 366 | |
| 367 | def _find_all_chromium_cookies_dbs(base_dir: Path) -> list[Path]: |
| 368 | """Return ALL candidate Cookies DBs under base_dir, best-guess order first. |
| 369 | |
| 370 | Order: Default, the base dir itself (Opera's flat layout), then numbered |
| 371 | "Profile N" dirs by most-recently-modified. Unlike _find_chromium_cookies_db |
| 372 | (which returns the first DB that merely EXISTS), this returns every profile |
| 373 | so the caller can pick the one that actually holds the target domain's |
| 374 | cookies. Needed because a logged-in session often lives in a non-Default |
| 375 | profile while Default still has a (guest-only) cookie DB. |
| 376 | """ |
| 377 | paths: list[Path] = [] |
| 378 | seen: set[Path] = set() |
| 379 | |
| 380 | def add(p: Optional[Path]) -> None: |
| 381 | if p is not None and p not in seen: |
| 382 | seen.add(p) |
| 383 | paths.append(p) |
| 384 | |
| 385 | add(_profile_cookie_db(base_dir / "Default")) |
| 386 | add(_profile_cookie_db(base_dir)) |
| 387 | try: |
| 388 | candidates = [ |
| 389 | child for child in base_dir.iterdir() |
| 390 | if child.is_dir() and child.name.startswith("Profile ") |
| 391 | ] |
| 392 | for child in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True): |
| 393 | add(_profile_cookie_db(child)) |
| 394 | except OSError: |
| 395 | pass |
| 396 | return paths |
| 397 | |
| 398 | |
| 399 | def _extract_chromium_cookies_any_profile( |
| 400 | base_dir: Path, keychain_service: str, domain: str, cookie_names: list[str] |
| 401 | ) -> Optional[dict[str, str]]: |
| 402 | """Try every profile under base_dir and return the best cookie match. |
| 403 | |
| 404 | Returns the first profile that yields ALL requested cookie_names. If no |
| 405 | profile has the complete set, returns the first partial match found, or |
| 406 | None if no profile yielded any. This fixes the single-profile limitation |
| 407 | where a guest-only Default profile shadowed a logged-in "Profile N". |
| 408 | """ |
| 409 | db_paths = _find_all_chromium_cookies_dbs(base_dir) |
| 410 | if not db_paths: |
| 411 | logger.info("%s cookies database not found under %s", keychain_service, base_dir) |
| 412 | return None |
| 413 | best: Optional[dict[str, str]] = None |
| 414 | key_cache: dict[str, Optional[bytes]] = {} |
| 415 | for db_path in db_paths: |
| 416 | got = _extract_chromium_cookies_macos( |
| 417 | db_path, keychain_service, domain, cookie_names, key_cache=key_cache |
| 418 | ) |
| 419 | if got: |
| 420 | if all(name in got for name in cookie_names): |
| 421 | logger.debug("Found complete cookie set for %s in %s", domain, db_path) |
| 422 | return got |
| 423 | if best is None: |
| 424 | best = got |
| 425 | return best |
| 426 | |
| 427 | |
| 428 | def _find_brave_cookies_db() -> Optional[Path]: |
| 429 | """Find Brave's Cookies database on macOS (Default, then Profile N).""" |
| 430 | return _find_chromium_cookies_db(BRAVE_BASE_DIR) |
| 431 | |
| 432 | |
| 433 | def extract_brave_cookies_macos(domain: str, cookie_names: list[str]) -> Optional[dict[str, str]]: |
| 434 | """Extract cookies from Brave on macOS. |
| 435 | |
| 436 | Brave uses the same v10 AES-128-CBC encryption as Chrome; only the DB |
| 437 | path and Keychain service name differ. |
| 438 | """ |
| 439 | return _extract_chromium_cookies_any_profile( |
| 440 | BRAVE_BASE_DIR, "Brave Safe Storage", domain, cookie_names |
| 441 | ) |
| 442 | |
| 443 | |
| 444 | def extract_chromium_browser_cookies_macos( |
| 445 | browser: str, domain: str, cookie_names: list[str] |
| 446 | ) -> Optional[dict[str, str]]: |
| 447 | """Extract cookies from a registry-defined Chromium browser on macOS. |
| 448 | |
| 449 | Covers every browser in CHROMIUM_BROWSER_PROFILES (Edge, Vivaldi, Opera, |
| 450 | Arc, Chromium). They all reuse Chrome's v10 AES-128-CBC encryption; only |
| 451 | the profile directory and Keychain service name differ. |
| 452 | """ |
| 453 | spec = CHROMIUM_BROWSER_PROFILES.get(browser) |
| 454 | if spec is None: |
| 455 | logger.debug("Unknown Chromium browser: %s", browser) |
| 456 | return None |
| 457 | base_dir, keychain_service = spec |
| 458 | return _extract_chromium_cookies_any_profile( |
| 459 | base_dir, keychain_service, domain, cookie_names |
| 460 | ) |
| 461 |