323 lines
11 KiB
Python
323 lines
11 KiB
Python
"""Chrome and Brave cookie extraction for macOS.
|
|
|
|
Extracts cookies from Chromium-based browser SQLite databases using only
|
|
stdlib modules and the system openssl CLI (ships with macOS). Zero pip
|
|
dependencies.
|
|
|
|
Chromium on macOS uses v10 encryption (AES-128-CBC with Keychain-stored key).
|
|
Chrome and Brave share the same algorithm; only the DB path and Keychain
|
|
service name differ.
|
|
This is NOT affected by Windows App-Bound Encryption (v20).
|
|
"""
|
|
|
|
import hashlib
|
|
import logging
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Cookie DB locations on macOS
|
|
CHROME_COOKIES_DB = Path.home() / "Library" / "Application Support" / "Google" / "Chrome" / "Default" / "Cookies"
|
|
BRAVE_BASE_DIR = Path.home() / "Library" / "Application Support" / "BraveSoftware" / "Brave-Browser"
|
|
|
|
# Chromium v10 encryption constants (shared by Chrome and Brave)
|
|
CHROME_SALT = b"saltysalt"
|
|
CHROME_PBKDF2_ITERATIONS = 1003
|
|
CHROME_KEY_LENGTH = 16
|
|
# IV is 16 space characters (0x20)
|
|
CHROME_IV_HEX = "20" * 16
|
|
|
|
|
|
def _get_chromium_encryption_key(service_name: str) -> Optional[bytes]:
|
|
"""Retrieve the encryption passphrase for a Chromium-based browser from macOS Keychain.
|
|
|
|
Calls `security find-generic-password` which may trigger a system dialog
|
|
on first access.
|
|
|
|
Returns the raw passphrase bytes, or None on failure.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["security", "find-generic-password", "-w", "-s", service_name],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=10,
|
|
)
|
|
if result.returncode != 0:
|
|
logger.info("%s Keychain access denied or browser not installed: %s", service_name, result.stderr.strip())
|
|
return None
|
|
passphrase = result.stdout.strip()
|
|
if not passphrase:
|
|
logger.info("%s Keychain returned empty passphrase", service_name)
|
|
return None
|
|
return passphrase.encode("utf-8")
|
|
except FileNotFoundError:
|
|
logger.info("'security' command not found — not on macOS?")
|
|
return None
|
|
except subprocess.TimeoutExpired:
|
|
logger.info("%s Keychain access timed out", service_name)
|
|
return None
|
|
except Exception as e:
|
|
logger.info("Failed to get %s encryption key: %s", service_name, e)
|
|
return None
|
|
|
|
|
|
def _get_chrome_encryption_key() -> Optional[bytes]:
|
|
return _get_chromium_encryption_key("Chrome Safe Storage")
|
|
|
|
|
|
def _derive_aes_key(passphrase: bytes) -> bytes:
|
|
"""Derive 16-byte AES key from Chrome's Keychain passphrase via PBKDF2."""
|
|
return hashlib.pbkdf2_hmac(
|
|
"sha1",
|
|
passphrase,
|
|
CHROME_SALT,
|
|
CHROME_PBKDF2_ITERATIONS,
|
|
dklen=CHROME_KEY_LENGTH,
|
|
)
|
|
|
|
|
|
def _decrypt_v10_value(encrypted_value: bytes, aes_key: bytes, db_version: int) -> Optional[str]:
|
|
"""Decrypt a Chrome v10-encrypted cookie value.
|
|
|
|
Uses system openssl CLI for AES-128-CBC decryption (zero pip deps).
|
|
For Chrome 130+ (db_version >= 24), strips 32-byte SHA-256 prefix after decryption.
|
|
|
|
Returns decrypted string or None on failure.
|
|
"""
|
|
# Strip the 'v10' prefix
|
|
ciphertext = encrypted_value[3:]
|
|
if not ciphertext:
|
|
return None
|
|
|
|
hex_key = aes_key.hex()
|
|
|
|
try:
|
|
result = subprocess.run(
|
|
[
|
|
"openssl", "enc", "-aes-128-cbc", "-d",
|
|
"-K", hex_key,
|
|
"-iv", CHROME_IV_HEX,
|
|
"-nopad",
|
|
],
|
|
input=ciphertext,
|
|
capture_output=True,
|
|
timeout=5,
|
|
)
|
|
if result.returncode != 0:
|
|
logger.debug("openssl decryption failed: %s", result.stderr.decode(errors="replace").strip())
|
|
return None
|
|
|
|
decrypted = result.stdout
|
|
if not decrypted:
|
|
return None
|
|
|
|
# Remove PKCS7 padding
|
|
decrypted = _remove_pkcs7_padding(decrypted)
|
|
if decrypted is None:
|
|
return None
|
|
|
|
# Chrome 130+ (db version >= 24): strip 32-byte SHA-256 prefix
|
|
if db_version >= 24 and len(decrypted) > 32:
|
|
decrypted = decrypted[32:]
|
|
|
|
return decrypted.decode("utf-8", errors="replace")
|
|
|
|
except FileNotFoundError:
|
|
logger.info("openssl not found — cannot decrypt Chrome cookies")
|
|
return None
|
|
except subprocess.TimeoutExpired:
|
|
logger.info("openssl decryption timed out")
|
|
return None
|
|
except Exception as e:
|
|
logger.debug("Chrome cookie decryption error: %s", e)
|
|
return None
|
|
|
|
|
|
def _remove_pkcs7_padding(data: bytes) -> Optional[bytes]:
|
|
"""Remove PKCS7 padding from decrypted data.
|
|
|
|
The last byte indicates the number of padding bytes added.
|
|
All padding bytes must have the same value.
|
|
|
|
Returns unpadded data or None if padding is invalid.
|
|
"""
|
|
if not data:
|
|
return None
|
|
pad_len = data[-1]
|
|
if pad_len < 1 or pad_len > 16:
|
|
return None
|
|
# Verify all padding bytes match
|
|
if data[-pad_len:] != bytes([pad_len]) * pad_len:
|
|
return None
|
|
return data[:-pad_len]
|
|
|
|
|
|
def _get_db_version(cursor: sqlite3.Cursor) -> int:
|
|
"""Get Chrome cookie database version from the meta table.
|
|
|
|
Returns 0 if meta table doesn't exist or version can't be read.
|
|
"""
|
|
try:
|
|
cursor.execute("SELECT value FROM meta WHERE key = 'version'")
|
|
row = cursor.fetchone()
|
|
if row:
|
|
return int(row[0])
|
|
except Exception:
|
|
pass
|
|
return 0
|
|
|
|
|
|
def _extract_chromium_cookies_macos(
|
|
db_path: Path,
|
|
keychain_service: str,
|
|
domain: str,
|
|
cookie_names: list[str],
|
|
) -> Optional[dict[str, str]]:
|
|
"""Extract cookies from any Chromium-based browser on macOS.
|
|
|
|
Copies the locked Cookies database to a temp file, reads specified cookies,
|
|
and decrypts v10-encrypted values using the Keychain-stored key.
|
|
|
|
Args:
|
|
db_path: Path to the browser's Cookies SQLite file.
|
|
keychain_service: macOS Keychain service name (e.g. "Chrome Safe Storage").
|
|
domain: Cookie domain to match (e.g., ".twitter.com", ".x.com").
|
|
cookie_names: List of cookie names to extract.
|
|
|
|
Returns:
|
|
Dict mapping cookie name to decrypted value, or None on failure.
|
|
Only includes cookies that were successfully found and decrypted.
|
|
"""
|
|
if not db_path.exists():
|
|
logger.info("%s cookies database not found at %s", keychain_service, db_path)
|
|
return None
|
|
|
|
passphrase = _get_chromium_encryption_key(keychain_service)
|
|
aes_key = _derive_aes_key(passphrase) if passphrase else None
|
|
|
|
# Copy DB to temp file (browser locks the original while running)
|
|
tmp_fd = None
|
|
tmp_path = None
|
|
try:
|
|
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".sqlite")
|
|
shutil.copy2(str(db_path), tmp_path)
|
|
except Exception as e:
|
|
logger.info("Failed to copy %s cookies database: %s", keychain_service, e)
|
|
if tmp_path:
|
|
try:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
finally:
|
|
if tmp_fd is not None:
|
|
import os
|
|
os.close(tmp_fd)
|
|
|
|
try:
|
|
conn = sqlite3.connect(tmp_path)
|
|
cursor = conn.cursor()
|
|
|
|
db_version = _get_db_version(cursor)
|
|
logger.debug("%s cookie DB version: %d", keychain_service, db_version)
|
|
|
|
placeholders = ",".join("?" for _ in cookie_names)
|
|
query = (
|
|
f"SELECT name, value, encrypted_value FROM cookies "
|
|
f"WHERE host_key LIKE ? AND name IN ({placeholders})"
|
|
)
|
|
params = [f"%{domain}"] + list(cookie_names)
|
|
cursor.execute(query, params)
|
|
|
|
results: dict[str, str] = {}
|
|
for name, value, encrypted_value in cursor.fetchall():
|
|
if value:
|
|
results[name] = value
|
|
continue
|
|
|
|
if encrypted_value and encrypted_value[:3] == b"v10":
|
|
if aes_key is None:
|
|
logger.debug("Skipping encrypted cookie %s — no Keychain access", name)
|
|
continue
|
|
decrypted = _decrypt_v10_value(encrypted_value, aes_key, db_version)
|
|
if decrypted:
|
|
results[name] = decrypted
|
|
else:
|
|
logger.debug("Failed to decrypt cookie %s", name)
|
|
elif encrypted_value:
|
|
logger.debug("Unknown encryption for cookie %s (prefix: %r)", name, encrypted_value[:3])
|
|
|
|
conn.close()
|
|
|
|
if not results:
|
|
logger.info("No matching cookies found in %s for domain %s", keychain_service, domain)
|
|
return None
|
|
|
|
return results
|
|
|
|
except sqlite3.Error as e:
|
|
logger.info("Failed to read %s cookies database: %s", keychain_service, e)
|
|
return None
|
|
except Exception as e:
|
|
logger.info("Unexpected error reading %s cookies: %s", keychain_service, e)
|
|
return None
|
|
finally:
|
|
try:
|
|
Path(tmp_path).unlink(missing_ok=True)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def extract_chrome_cookies_macos(domain: str, cookie_names: list[str]) -> Optional[dict[str, str]]:
|
|
"""Extract cookies from Chrome on macOS."""
|
|
return _extract_chromium_cookies_macos(
|
|
CHROME_COOKIES_DB, "Chrome Safe Storage", domain, cookie_names
|
|
)
|
|
|
|
|
|
def _find_brave_cookies_db() -> Optional[Path]:
|
|
"""Find Brave's Cookies database on macOS.
|
|
|
|
Tries the Default profile first, then scans numbered Profile directories
|
|
by most-recently-modified. Brave creates extra profiles as "Profile 1",
|
|
"Profile 2", etc. alongside Default; the most recently used one is the
|
|
likeliest to hold current cookies. Lexicographic sort would visit
|
|
"Profile 10" before "Profile 2", which can return the wrong profile.
|
|
"""
|
|
default = BRAVE_BASE_DIR / "Default" / "Cookies"
|
|
if default.exists():
|
|
return default
|
|
|
|
try:
|
|
candidates = [
|
|
child for child in BRAVE_BASE_DIR.iterdir()
|
|
if child.is_dir() and child.name.startswith("Profile ")
|
|
]
|
|
for child in sorted(candidates, key=lambda p: p.stat().st_mtime, reverse=True):
|
|
candidate = child / "Cookies"
|
|
if candidate.exists():
|
|
return candidate
|
|
except OSError:
|
|
pass
|
|
|
|
return None
|
|
|
|
|
|
def extract_brave_cookies_macos(domain: str, cookie_names: list[str]) -> Optional[dict[str, str]]:
|
|
"""Extract cookies from Brave on macOS.
|
|
|
|
Brave uses the same v10 AES-128-CBC encryption as Chrome; only the DB
|
|
path and Keychain service name differ.
|
|
"""
|
|
db_path = _find_brave_cookies_db()
|
|
if db_path is None:
|
|
logger.info("Brave cookies database not found under %s", BRAVE_BASE_DIR)
|
|
return None
|
|
return _extract_chromium_cookies_macos(db_path, "Brave Safe Storage", domain, cookie_names)
|