feat(cookies): add Brave browser cookie extraction for macOS
Brave uses identical v10 AES-128-CBC encryption to Chrome; only the
DB path (BraveSoftware/Brave-Browser) and Keychain service name
("Brave Safe Storage") differ. Refactored chrome_cookies.py to share
a single _extract_chromium_cookies_macos helper rather than duplicating
the decryption logic.
Profile discovery tries Default/ first, then scans numbered Profile N/
directories so non-default Brave profiles are covered.
This commit is contained in:
@@ -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,67 @@ 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
|
||||||
|
in creation order. Brave creates extra profiles as "Profile 1", "Profile 2",
|
||||||
|
etc. alongside Default.
|
||||||
|
"""
|
||||||
|
default = BRAVE_BASE_DIR / "Default" / "Cookies"
|
||||||
|
if default.exists():
|
||||||
|
return default
|
||||||
|
|
||||||
|
try:
|
||||||
|
for child in sorted(BRAVE_BASE_DIR.iterdir()):
|
||||||
|
if child.is_dir() and child.name.startswith("Profile "):
|
||||||
|
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:
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user