Merge pull request #320 from kaushikgopal/feat/brave-cookie-extraction

Add Brave browser support for X/Twitter cookie extraction
This commit is contained in:
Trevin Chow
2026-05-17 00:56:44 -07:00
committed by GitHub
3 changed files with 121 additions and 40 deletions
+90 -33
View File
@@ -1,9 +1,12 @@
"""Chrome cookie extraction for macOS. """Chrome and Brave cookie extraction for macOS.
Extracts cookies from Chrome's encrypted SQLite database using only stdlib Extracts cookies from Chromium-based browser SQLite databases using only
modules and the system openssl CLI (ships with macOS). Zero pip dependencies. stdlib modules and the system openssl CLI (ships with macOS). Zero pip
dependencies.
Chrome on macOS uses v10 encryption (AES-128-CBC with Keychain-stored key). 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). This is NOT affected by Windows App-Bound Encryption (v20).
""" """
@@ -18,10 +21,11 @@ from typing import Optional
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Chrome cookie DB location on macOS # Cookie DB locations on macOS
CHROME_COOKIES_DB = Path.home() / "Library" / "Application Support" / "Google" / "Chrome" / "Default" / "Cookies" CHROME_COOKIES_DB = Path.home() / "Library" / "Application Support" / "Google" / "Chrome" / "Default" / "Cookies"
BRAVE_BASE_DIR = Path.home() / "Library" / "Application Support" / "BraveSoftware" / "Brave-Browser"
# Chrome v10 encryption constants # Chromium v10 encryption constants (shared by Chrome and Brave)
CHROME_SALT = b"saltysalt" CHROME_SALT = b"saltysalt"
CHROME_PBKDF2_ITERATIONS = 1003 CHROME_PBKDF2_ITERATIONS = 1003
CHROME_KEY_LENGTH = 16 CHROME_KEY_LENGTH = 16
@@ -29,8 +33,8 @@ CHROME_KEY_LENGTH = 16
CHROME_IV_HEX = "20" * 16 CHROME_IV_HEX = "20" * 16
def _get_chrome_encryption_key() -> Optional[bytes]: def _get_chromium_encryption_key(service_name: str) -> Optional[bytes]:
"""Retrieve Chrome's encryption passphrase from macOS Keychain. """Retrieve the encryption passphrase for a Chromium-based browser from macOS Keychain.
Calls `security find-generic-password` which may trigger a system dialog Calls `security find-generic-password` which may trigger a system dialog
on first access. on first access.
@@ -39,30 +43,34 @@ def _get_chrome_encryption_key() -> Optional[bytes]:
""" """
try: try:
result = subprocess.run( result = subprocess.run(
["security", "find-generic-password", "-w", "-s", "Chrome Safe Storage"], ["security", "find-generic-password", "-w", "-s", service_name],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=10, timeout=10,
) )
if result.returncode != 0: if result.returncode != 0:
logger.info("Chrome Keychain access denied or Chrome not installed: %s", result.stderr.strip()) logger.info("%s Keychain access denied or browser not installed: %s", service_name, result.stderr.strip())
return None return None
passphrase = result.stdout.strip() passphrase = result.stdout.strip()
if not passphrase: if not passphrase:
logger.info("Chrome Keychain returned empty passphrase") logger.info("%s Keychain returned empty passphrase", service_name)
return None return None
return passphrase.encode("utf-8") return passphrase.encode("utf-8")
except FileNotFoundError: except FileNotFoundError:
logger.info("'security' command not found — not on macOS?") logger.info("'security' command not found — not on macOS?")
return None return None
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.info("Chrome Keychain access timed out") logger.info("%s Keychain access timed out", service_name)
return None return None
except Exception as e: except Exception as e:
logger.info("Failed to get Chrome encryption key: %s", e) logger.info("Failed to get %s encryption key: %s", service_name, e)
return None return None
def _get_chrome_encryption_key() -> Optional[bytes]:
return _get_chromium_encryption_key("Chrome Safe Storage")
def _derive_aes_key(passphrase: bytes) -> bytes: def _derive_aes_key(passphrase: bytes) -> bytes:
"""Derive 16-byte AES key from Chrome's Keychain passphrase via PBKDF2.""" """Derive 16-byte AES key from Chrome's Keychain passphrase via PBKDF2."""
return hashlib.pbkdf2_hmac( return hashlib.pbkdf2_hmac(
@@ -165,36 +173,42 @@ def _get_db_version(cursor: sqlite3.Cursor) -> int:
return 0 return 0
def extract_chrome_cookies_macos(domain: str, cookie_names: list[str]) -> Optional[dict[str, str]]: def _extract_chromium_cookies_macos(
"""Extract cookies from Chrome on 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, Copies the locked Cookies database to a temp file, reads specified cookies,
and decrypts v10-encrypted values using the Keychain-stored key. and decrypts v10-encrypted values using the Keychain-stored key.
Args: Args:
domain: Cookie domain to match (e.g., ".twitter.com", ".x.com") db_path: Path to the browser's Cookies SQLite file.
cookie_names: List of cookie names to extract 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: Returns:
Dict mapping cookie name to decrypted value, or None on failure. Dict mapping cookie name to decrypted value, or None on failure.
Only includes cookies that were successfully found and decrypted. Only includes cookies that were successfully found and decrypted.
""" """
if not CHROME_COOKIES_DB.exists(): if not db_path.exists():
logger.info("Chrome cookies database not found at %s", CHROME_COOKIES_DB) logger.info("%s cookies database not found at %s", keychain_service, db_path)
return None return None
# Get encryption key from Keychain passphrase = _get_chromium_encryption_key(keychain_service)
passphrase = _get_chrome_encryption_key()
aes_key = _derive_aes_key(passphrase) if passphrase else None aes_key = _derive_aes_key(passphrase) if passphrase else None
# Copy DB to temp file (Chrome locks the original) # Copy DB to temp file (browser locks the original while running)
tmp_fd = None tmp_fd = None
tmp_path = None tmp_path = None
try: try:
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".sqlite") tmp_fd, tmp_path = tempfile.mkstemp(suffix=".sqlite")
shutil.copy2(str(CHROME_COOKIES_DB), tmp_path) shutil.copy2(str(db_path), tmp_path)
except Exception as e: except Exception as e:
logger.info("Failed to copy Chrome cookies database: %s", e) logger.info("Failed to copy %s cookies database: %s", keychain_service, e)
if tmp_path: if tmp_path:
try: try:
Path(tmp_path).unlink(missing_ok=True) Path(tmp_path).unlink(missing_ok=True)
@@ -211,26 +225,22 @@ def extract_chrome_cookies_macos(domain: str, cookie_names: list[str]) -> Option
cursor = conn.cursor() cursor = conn.cursor()
db_version = _get_db_version(cursor) db_version = _get_db_version(cursor)
logger.debug("Chrome cookie DB version: %d", db_version) logger.debug("%s cookie DB version: %d", keychain_service, db_version)
# Build query with placeholders for cookie names
placeholders = ",".join("?" for _ in cookie_names) placeholders = ",".join("?" for _ in cookie_names)
query = ( query = (
f"SELECT name, value, encrypted_value FROM cookies " f"SELECT name, value, encrypted_value FROM cookies "
f"WHERE host_key LIKE ? AND name IN ({placeholders})" f"WHERE host_key LIKE ? AND name IN ({placeholders})"
) )
# Use LIKE for domain matching (e.g., %.twitter.com matches .twitter.com)
params = [f"%{domain}"] + list(cookie_names) params = [f"%{domain}"] + list(cookie_names)
cursor.execute(query, params) cursor.execute(query, params)
results: dict[str, str] = {} results: dict[str, str] = {}
for name, value, encrypted_value in cursor.fetchall(): for name, value, encrypted_value in cursor.fetchall():
# Prefer unencrypted value if present
if value: if value:
results[name] = value results[name] = value
continue continue
# Handle encrypted value
if encrypted_value and encrypted_value[:3] == b"v10": if encrypted_value and encrypted_value[:3] == b"v10":
if aes_key is None: if aes_key is None:
logger.debug("Skipping encrypted cookie %s — no Keychain access", name) logger.debug("Skipping encrypted cookie %s — no Keychain access", name)
@@ -241,25 +251,72 @@ def extract_chrome_cookies_macos(domain: str, cookie_names: list[str]) -> Option
else: else:
logger.debug("Failed to decrypt cookie %s", name) logger.debug("Failed to decrypt cookie %s", name)
elif encrypted_value: elif encrypted_value:
# Unknown encryption version
logger.debug("Unknown encryption for cookie %s (prefix: %r)", name, encrypted_value[:3]) logger.debug("Unknown encryption for cookie %s (prefix: %r)", name, encrypted_value[:3])
conn.close() conn.close()
if not results: if not results:
logger.info("No matching cookies found in Chrome for domain %s", domain) logger.info("No matching cookies found in %s for domain %s", keychain_service, domain)
return None return None
return results return results
except sqlite3.Error as e: except sqlite3.Error as e:
logger.info("Failed to read Chrome cookies database: %s", e) logger.info("Failed to read %s cookies database: %s", keychain_service, e)
return None return None
except Exception as e: except Exception as e:
logger.info("Unexpected error reading Chrome cookies: %s", e) logger.info("Unexpected error reading %s cookies: %s", keychain_service, e)
return None return None
finally: finally:
try: try:
Path(tmp_path).unlink(missing_ok=True) Path(tmp_path).unlink(missing_ok=True)
except Exception: except Exception:
pass 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)
@@ -1,6 +1,6 @@
"""Browser cookie extraction for last30days. """Browser cookie extraction for last30days.
Extracts cookies from local browser databases (Firefox, Chrome, Safari) Extracts cookies from local browser databases (Firefox, Chrome, Brave, Safari)
to enable zero-config authentication for services like X/Twitter. to enable zero-config authentication for services like X/Twitter.
Only uses Python stdlib — no external dependencies. Only uses Python stdlib — no external dependencies.
@@ -255,6 +255,29 @@ def extract_chrome_cookies(
return None return None
def extract_brave_cookies(
domain: str, cookie_names: List[str]
) -> Optional[Dict[str, str]]:
"""Extract cookies from Brave for the given domain and cookie names.
macOS only — Brave uses the same v10 AES-128-CBC encryption as Chrome,
with a different DB path and Keychain service name ("Brave Safe Storage").
Tries the Default profile first, then scans numbered Profile directories.
Returns:
Dict of {cookie_name: cookie_value} or None if extraction fails.
"""
if platform.system() != "Darwin":
logger.debug("Brave cookie extraction only supported on macOS")
return None
try:
from .chrome_cookies import extract_brave_cookies_macos
return extract_brave_cookies_macos(domain, cookie_names)
except Exception as exc:
logger.debug("Brave cookie extraction failed: %s", exc)
return None
def extract_safari_cookies( def extract_safari_cookies(
domain: str, cookie_names: List[str] domain: str, cookie_names: List[str]
) -> Optional[Dict[str, str]]: ) -> Optional[Dict[str, str]]:
@@ -282,9 +305,9 @@ def extract_cookies(
"""Extract cookies from the specified browser. """Extract cookies from the specified browser.
Args: Args:
browser: One of 'firefox', 'chrome', 'safari', or 'auto'. browser: One of 'firefox', 'chrome', 'brave', 'safari', or 'auto'.
'auto' tries browsers in platform-appropriate order: 'auto' tries browsers in platform-appropriate order:
- macOS: Chrome -> Firefox -> Safari - macOS: Chrome -> Brave -> Firefox -> Safari
- Linux: Firefox only - Linux: Firefox only
domain: The cookie domain to match (e.g. ".x.com"). domain: The cookie domain to match (e.g. ".x.com").
cookie_names: List of cookie names to extract. cookie_names: List of cookie names to extract.
@@ -333,7 +356,7 @@ def extract_cookies_with_source(
so callers can track the source. so callers can track the source.
Args: Args:
browser: One of 'firefox', 'chrome', 'safari', or 'auto'. browser: One of 'firefox', 'chrome', 'brave', 'safari', or 'auto'.
domain: The cookie domain to match (e.g. ".x.com"). domain: The cookie domain to match (e.g. ".x.com").
cookie_names: List of cookie names to extract. cookie_names: List of cookie names to extract.
@@ -344,6 +367,7 @@ def extract_cookies_with_source(
extractors = { extractors = {
"firefox": extract_firefox_cookies, "firefox": extract_firefox_cookies,
"chrome": extract_chrome_cookies, "chrome": extract_chrome_cookies,
"brave": extract_brave_cookies,
"safari": extract_safari_cookies, "safari": extract_safari_cookies,
} }
@@ -360,7 +384,7 @@ def extract_cookies_with_source(
# Auto mode: try browsers in platform-appropriate order # Auto mode: try browsers in platform-appropriate order
system = platform.system() system = platform.system()
if system == "Darwin": if system == "Darwin":
order = ["chrome", "firefox", "safari"] order = ["chrome", "brave", "firefox", "safari"]
elif system == "Linux": elif system == "Linux":
order = ["firefox"] order = ["firefox"]
else: else:
+2 -2
View File
@@ -286,7 +286,7 @@ class TestFullExtraction:
with mock.patch("scripts.lib.chrome_cookies.CHROME_COOKIES_DB", Path(db_path)): with mock.patch("scripts.lib.chrome_cookies.CHROME_COOKIES_DB", Path(db_path)):
with mock.patch( with mock.patch(
"scripts.lib.chrome_cookies._get_chrome_encryption_key", "scripts.lib.chrome_cookies._get_chromium_encryption_key",
return_value=KNOWN_PASSPHRASE, return_value=KNOWN_PASSPHRASE,
): ):
result = extract_chrome_cookies_macos(".x.com", ["auth_token", "ct0"]) result = extract_chrome_cookies_macos(".x.com", ["auth_token", "ct0"])
@@ -319,7 +319,7 @@ class TestFullExtraction:
with mock.patch("scripts.lib.chrome_cookies.CHROME_COOKIES_DB", Path(db_path)): with mock.patch("scripts.lib.chrome_cookies.CHROME_COOKIES_DB", Path(db_path)):
with mock.patch( with mock.patch(
"scripts.lib.chrome_cookies._get_chrome_encryption_key", "scripts.lib.chrome_cookies._get_chromium_encryption_key",
return_value=KNOWN_PASSPHRASE, return_value=KNOWN_PASSPHRASE,
): ):
result = extract_chrome_cookies_macos(".x.com", ["auth_token"]) result = extract_chrome_cookies_macos(".x.com", ["auth_token"])