返回 last30days-skill
chrome_cookies.py
根目录 / skills / last30days / scripts / lib / chrome_cookies.py
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
461 lines PYTHON