返回 last30days-skill
test_chrome_cookies.py
根目录 / tests / test_chrome_cookies.py
1 """Tests for Chrome cookie extraction on macOS."""
2
3 import hashlib
4 import os
5 import sqlite3
6 import shutil
7 import subprocess
8 import tempfile
9 from pathlib import Path
10 from unittest import mock
11
12 import pytest
13
14 OPENSSL_AVAILABLE = shutil.which("openssl") is not None
15
16 from lib import chrome_cookies
17 from lib.chrome_cookies import (
18 CHROME_COOKIES_DB,
19 CHROME_IV_HEX,
20 CHROME_KEY_LENGTH,
21 CHROME_PBKDF2_ITERATIONS,
22 CHROME_SALT,
23 _derive_aes_key,
24 _get_chrome_encryption_key,
25 _get_db_version,
26 _remove_pkcs7_padding,
27 _extract_chromium_cookies_macos,
28 _decrypt_v10_value,
29 _find_chromium_cookies_db,
30 extract_chrome_cookies_macos,
31 )
32
33 # ---------------------------------------------------------------------------
34 # Helpers — create real encrypted cookie values using known key + system openssl
35 # ---------------------------------------------------------------------------
36
37 KNOWN_PASSPHRASE = b"test_passphrase_for_unit_tests"
38 KNOWN_AES_KEY = _derive_aes_key(KNOWN_PASSPHRASE)
39
40
41 def _encrypt_value_v10(plaintext: str, aes_key: bytes) -> bytes:
42 """Encrypt a value the same way Chrome v10 does, using system openssl.
43
44 Returns b'v10' + AES-128-CBC ciphertext with PKCS7 padding.
45 """
46 hex_key = aes_key.hex()
47 result = subprocess.run(
48 [
49 "openssl", "enc", "-aes-128-cbc", "-e",
50 "-K", hex_key,
51 "-iv", CHROME_IV_HEX,
52 ],
53 input=plaintext.encode("utf-8"),
54 capture_output=True,
55 timeout=5,
56 )
57 assert result.returncode == 0, f"openssl encrypt failed: {result.stderr}"
58 return b"v10" + result.stdout
59
60
61 def _encrypt_value_v10_with_sha_prefix(plaintext: str, aes_key: bytes) -> bytes:
62 """Encrypt with a 32-byte SHA-256 prefix (Chrome 130+ style)."""
63 raw = b"\x00" * 32 + plaintext.encode("utf-8")
64 hex_key = aes_key.hex()
65 result = subprocess.run(
66 [
67 "openssl", "enc", "-aes-128-cbc", "-e",
68 "-K", hex_key,
69 "-iv", CHROME_IV_HEX,
70 ],
71 input=raw,
72 capture_output=True,
73 timeout=5,
74 )
75 assert result.returncode == 0, f"openssl encrypt failed: {result.stderr}"
76 return b"v10" + result.stdout
77
78
79 def _create_chrome_cookies_db(path: str, cookies: list[tuple], db_version: int = 20) -> None:
80 """Create a minimal Chrome Cookies SQLite database.
81
82 cookies: list of (host_key, name, value, encrypted_value) tuples
83 """
84 conn = sqlite3.connect(path)
85 c = conn.cursor()
86 c.execute("CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT)")
87 c.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('version', ?)", (str(db_version),))
88 c.execute(
89 "CREATE TABLE IF NOT EXISTS cookies ("
90 " host_key TEXT NOT NULL,"
91 " name TEXT NOT NULL,"
92 " value TEXT NOT NULL DEFAULT '',"
93 " encrypted_value BLOB NOT NULL DEFAULT x'',"
94 " path TEXT NOT NULL DEFAULT '/',"
95 " expires_utc INTEGER NOT NULL DEFAULT 0,"
96 " is_secure INTEGER NOT NULL DEFAULT 1,"
97 " is_httponly INTEGER NOT NULL DEFAULT 1,"
98 " creation_utc INTEGER NOT NULL DEFAULT 0,"
99 " last_access_utc INTEGER NOT NULL DEFAULT 0,"
100 " has_expires INTEGER NOT NULL DEFAULT 1,"
101 " is_persistent INTEGER NOT NULL DEFAULT 1,"
102 " priority INTEGER NOT NULL DEFAULT 1,"
103 " samesite INTEGER NOT NULL DEFAULT 0,"
104 " source_scheme INTEGER NOT NULL DEFAULT 2,"
105 " source_port INTEGER NOT NULL DEFAULT 443,"
106 " last_update_utc INTEGER NOT NULL DEFAULT 0"
107 ")"
108 )
109 for host_key, name, value, encrypted_value in cookies:
110 c.execute(
111 "INSERT INTO cookies (host_key, name, value, encrypted_value) VALUES (?, ?, ?, ?)",
112 (host_key, name, value, encrypted_value),
113 )
114 conn.commit()
115 conn.close()
116
117 # ---------------------------------------------------------------------------
118 # PKCS7 padding tests
119 # ---------------------------------------------------------------------------
120
121
122 class TestPkcs7Padding:
123 def test_valid_padding_1(self):
124 # 1 byte of padding
125 data = b"hello world!!!!!" + b"\x01"
126 assert _remove_pkcs7_padding(data) == b"hello world!!!!!"
127
128 def test_valid_padding_5(self):
129 data = b"hello world" + b"\x05\x05\x05\x05\x05"
130 assert _remove_pkcs7_padding(data) == b"hello world"
131
132 def test_valid_padding_16(self):
133 # Full block of padding
134 data = b"\x10" * 16
135 assert _remove_pkcs7_padding(data) == b""
136
137 def test_invalid_padding_zero(self):
138 data = b"hello\x00"
139 assert _remove_pkcs7_padding(data) is None
140
141 def test_invalid_padding_mismatch(self):
142 data = b"hello\x03\x03\x02"
143 assert _remove_pkcs7_padding(data) is None
144
145 def test_empty_data(self):
146 assert _remove_pkcs7_padding(b"") is None
147
148 # ---------------------------------------------------------------------------
149 # Key derivation test
150 # ---------------------------------------------------------------------------
151
152
153 class TestKeyDerivation:
154 def test_derive_aes_key_deterministic(self):
155 key1 = _derive_aes_key(b"my_passphrase")
156 key2 = _derive_aes_key(b"my_passphrase")
157 assert key1 == key2
158 assert len(key1) == 16
159
160 def test_derive_aes_key_different_passphrases(self):
161 key1 = _derive_aes_key(b"passphrase_a")
162 key2 = _derive_aes_key(b"passphrase_b")
163 assert key1 != key2
164
165 # ---------------------------------------------------------------------------
166 # Decryption test (real openssl, known key)
167 # ---------------------------------------------------------------------------
168
169
170 @pytest.mark.skipif(not OPENSSL_AVAILABLE, reason="openssl not installed")
171 class TestDecryption:
172 def test_decrypt_v10_roundtrip(self):
173 """Encrypt then decrypt — verifies the full pipeline works."""
174 original = "my_secret_cookie_value_12345"
175 encrypted = _encrypt_value_v10(original, KNOWN_AES_KEY)
176 assert encrypted[:3] == b"v10"
177
178 decrypted = _decrypt_v10_value(encrypted, KNOWN_AES_KEY, db_version=20)
179 assert decrypted == original
180
181 def test_decrypt_v10_chrome130_with_sha_prefix(self):
182 """Chrome 130+ (db_version >= 24) strips 32-byte SHA-256 prefix."""
183 original = "session_token_abc"
184 encrypted = _encrypt_value_v10_with_sha_prefix(original, KNOWN_AES_KEY)
185
186 decrypted = _decrypt_v10_value(encrypted, KNOWN_AES_KEY, db_version=24)
187 assert decrypted == original
188
189 def test_decrypt_wrong_key_returns_none_or_garbage(self):
190 """Wrong key should either fail decryption or produce garbage."""
191 original = "secret"
192 encrypted = _encrypt_value_v10(original, KNOWN_AES_KEY)
193 wrong_key = _derive_aes_key(b"wrong_passphrase")
194
195 result = _decrypt_v10_value(encrypted, wrong_key, db_version=20)
196 # Either None (padding check fails) or garbage (not the original)
197 assert result is None or result != original
198
199 def test_decrypt_empty_ciphertext(self):
200 """v10 prefix with no ciphertext should return None."""
201 assert _decrypt_v10_value(b"v10", KNOWN_AES_KEY, db_version=20) is None
202
203 # ---------------------------------------------------------------------------
204 # Chrome not installed → returns None
205 # ---------------------------------------------------------------------------
206
207
208 class TestChromeNotInstalled:
209 def test_db_not_found(self):
210 with mock.patch(
211 "lib.chrome_cookies._find_all_chromium_cookies_dbs",
212 return_value=[],
213 ):
214 result = extract_chrome_cookies_macos(".x.com", ["auth_token"])
215 assert result is None
216
217
218 class TestChromiumCookieDbFinder:
219 def test_legacy_single_db_finder_returns_first_all_profile_candidate(self, tmp_path):
220 first = tmp_path / "Default" / "Network" / "Cookies"
221 second = tmp_path / "Profile 1" / "Network" / "Cookies"
222
223 with mock.patch(
224 "lib.chrome_cookies._find_all_chromium_cookies_dbs",
225 return_value=[first, second],
226 ) as find_all:
227 result = _find_chromium_cookies_db(tmp_path)
228
229 assert result == first
230 find_all.assert_called_once_with(tmp_path)
231
232 # ---------------------------------------------------------------------------
233 # Keychain access denied → returns None
234 # ---------------------------------------------------------------------------
235
236
237 class TestKeychainDenied:
238 def test_security_command_fails(self):
239 with mock.patch("lib.chrome_cookies.subprocess.run") as mock_run:
240 mock_run.return_value = subprocess.CompletedProcess(
241 args=[], returncode=44, stdout="", stderr="security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain."
242 )
243 result = _get_chrome_encryption_key()
244 assert result is None
245
246 def test_security_command_not_found(self):
247 with mock.patch("lib.chrome_cookies.subprocess.run", side_effect=FileNotFoundError):
248 result = _get_chrome_encryption_key()
249 assert result is None
250
251 # ---------------------------------------------------------------------------
252 # openssl not found → returns None
253 # ---------------------------------------------------------------------------
254
255
256 @pytest.mark.skipif(not OPENSSL_AVAILABLE, reason="openssl not installed")
257 class TestOpensslNotFound:
258 def test_openssl_missing(self):
259 encrypted = _encrypt_value_v10("test", KNOWN_AES_KEY)
260 with mock.patch("lib.chrome_cookies.subprocess.run", side_effect=FileNotFoundError):
261 result = _decrypt_v10_value(encrypted, KNOWN_AES_KEY, db_version=20)
262 assert result is None
263
264 # ---------------------------------------------------------------------------
265 # Unencrypted cookie values → returned as-is
266 # ---------------------------------------------------------------------------
267
268
269 class TestUnencryptedCookies:
270 @pytest.mark.skipif(os.name == "nt", reason="POSIX permission bits are not reliable on Windows")
271 def test_temp_cookie_db_copy_is_owner_only(self, tmp_path):
272 """Copied Chromium cookie DB temp files are chmodded owner-only before read."""
273 db_path = tmp_path / "Cookies"
274 _create_chrome_cookies_db(str(db_path), [
275 (".x.com", "auth_token", "plain_token_value", b""),
276 ])
277 os.chmod(db_path, 0o644)
278
279 real_connect = sqlite3.connect
280
281 def assert_temp_copy_locked(path, *args, **kwargs):
282 if Path(str(path)) != db_path:
283 assert Path(str(path)).stat().st_mode & 0o777 == 0o600
284 return real_connect(path, *args, **kwargs)
285
286 with mock.patch("lib.chrome_cookies.sqlite3.connect", side_effect=assert_temp_copy_locked):
287 result = _extract_chromium_cookies_macos(
288 db_path,
289 "Chrome Safe Storage",
290 ".x.com",
291 ["auth_token"],
292 )
293
294 assert result == {"auth_token": "plain_token_value"}
295
296 @pytest.mark.skipif(os.name == "nt", reason="POSIX permission model does not apply on Windows")
297 def test_temp_cookie_copy_never_world_readable(self, tmp_path):
298 """The temp copy must stay private immediately after copying content."""
299 db_path = tmp_path / "Cookies"
300 _create_chrome_cookies_db(str(db_path), [
301 (".x.com", "auth_token", "plain_token_value", b""),
302 ])
303 os.chmod(db_path, 0o644)
304
305 observed = {}
306 real_lock = chrome_cookies._lock_temp_cookie_copy
307
308 def spy(path):
309 observed["mode_after_copy"] = os.stat(path).st_mode & 0o777
310 return real_lock(path)
311
312 with mock.patch.object(chrome_cookies, "_lock_temp_cookie_copy", side_effect=spy):
313 result = _extract_chromium_cookies_macos(
314 db_path,
315 "Chrome Safe Storage",
316 ".x.com",
317 ["auth_token"],
318 )
319
320 assert result == {"auth_token": "plain_token_value"}
321 assert observed["mode_after_copy"] == 0o600
322
323 def test_plain_value_returned(self, tmp_path):
324 """Unencrypted cookies (value column populated) returned without decryption."""
325 db_path = str(tmp_path / "Cookies")
326 _create_chrome_cookies_db(db_path, [
327 (".x.com", "auth_token", "plain_token_value", b""),
328 (".x.com", "ct0", "plain_ct0_value", b""),
329 ])
330
331 with mock.patch(
332 "lib.chrome_cookies._find_all_chromium_cookies_dbs",
333 return_value=[Path(db_path)],
334 ):
335 # No keychain needed for unencrypted values
336 with mock.patch("lib.chrome_cookies._get_chromium_encryption_key", return_value=None):
337 result = extract_chrome_cookies_macos(".x.com", ["auth_token", "ct0"])
338
339 assert result == {"auth_token": "plain_token_value", "ct0": "plain_ct0_value"}
340
341 # ---------------------------------------------------------------------------
342 # Full integration: mock DB with real v10 encryption, mock Keychain
343 # ---------------------------------------------------------------------------
344
345
346 class TestFullExtraction:
347 @pytest.mark.skipif(not OPENSSL_AVAILABLE, reason="openssl not installed")
348 def test_encrypted_cookies_extracted(self, tmp_path):
349 """End-to-end: create DB with real v10-encrypted values, extract them."""
350 auth_val = "my_auth_token_123"
351 ct0_val = "my_ct0_csrf_456"
352
353 encrypted_auth = _encrypt_value_v10(auth_val, KNOWN_AES_KEY)
354 encrypted_ct0 = _encrypt_value_v10(ct0_val, KNOWN_AES_KEY)
355
356 db_path = str(tmp_path / "Cookies")
357 _create_chrome_cookies_db(db_path, [
358 (".x.com", "auth_token", "", encrypted_auth),
359 (".x.com", "ct0", "", encrypted_ct0),
360 (".other.com", "other", "", b""), # unrelated cookie
361 ])
362
363 with mock.patch(
364 "lib.chrome_cookies._find_all_chromium_cookies_dbs",
365 return_value=[Path(db_path)],
366 ):
367 with mock.patch(
368 "lib.chrome_cookies._get_chromium_encryption_key",
369 return_value=KNOWN_PASSPHRASE,
370 ):
371 result = extract_chrome_cookies_macos(".x.com", ["auth_token", "ct0"])
372
373 assert result is not None
374 assert result["auth_token"] == auth_val
375 assert result["ct0"] == ct0_val
376
377 def test_no_matching_cookies_returns_none(self, tmp_path):
378 db_path = str(tmp_path / "Cookies")
379 _create_chrome_cookies_db(db_path, [
380 (".other.com", "session", "val", b""),
381 ])
382
383 with mock.patch(
384 "lib.chrome_cookies._find_all_chromium_cookies_dbs",
385 return_value=[Path(db_path)],
386 ):
387 with mock.patch("lib.chrome_cookies._get_chromium_encryption_key", return_value=None):
388 result = extract_chrome_cookies_macos(".x.com", ["auth_token"])
389
390 assert result is None
391
392 @pytest.mark.skipif(not OPENSSL_AVAILABLE, reason="openssl not installed")
393 def test_chrome130_db_version_24(self, tmp_path):
394 """Chrome 130+ with db_version >= 24 strips SHA-256 prefix."""
395 auth_val = "token_for_chrome130"
396 encrypted_auth = _encrypt_value_v10_with_sha_prefix(auth_val, KNOWN_AES_KEY)
397
398 db_path = str(tmp_path / "Cookies")
399 _create_chrome_cookies_db(db_path, [
400 (".x.com", "auth_token", "", encrypted_auth),
401 ], db_version=24)
402
403 with mock.patch(
404 "lib.chrome_cookies._find_all_chromium_cookies_dbs",
405 return_value=[Path(db_path)],
406 ):
407 with mock.patch(
408 "lib.chrome_cookies._get_chromium_encryption_key",
409 return_value=KNOWN_PASSPHRASE,
410 ):
411 result = extract_chrome_cookies_macos(".x.com", ["auth_token"])
412
413 assert result is not None
414 assert result["auth_token"] == auth_val
415
416 @pytest.mark.skipif(not OPENSSL_AVAILABLE, reason="openssl not installed")
417 def test_multi_profile_extraction_reuses_keychain_key(self, tmp_path):
418 """Profiles for one browser share a Keychain service; fetch it once."""
419 first_db = tmp_path / "Default.sqlite"
420 second_db = tmp_path / "Profile1.sqlite"
421 auth_val = "profile_auth_token"
422 ct0_val = "profile_ct0_value"
423
424 _create_chrome_cookies_db(str(first_db), [
425 (".x.com", "auth_token", "", _encrypt_value_v10(auth_val, KNOWN_AES_KEY)),
426 ])
427 _create_chrome_cookies_db(str(second_db), [
428 (".x.com", "auth_token", "", _encrypt_value_v10(auth_val, KNOWN_AES_KEY)),
429 (".x.com", "ct0", "", _encrypt_value_v10(ct0_val, KNOWN_AES_KEY)),
430 ])
431
432 with mock.patch(
433 "lib.chrome_cookies._find_all_chromium_cookies_dbs",
434 return_value=[first_db, second_db],
435 ):
436 with mock.patch(
437 "lib.chrome_cookies._get_chromium_encryption_key",
438 return_value=KNOWN_PASSPHRASE,
439 ) as get_key:
440 result = extract_chrome_cookies_macos(".x.com", ["auth_token", "ct0"])
441
442 assert result is not None
443 assert result["ct0"] == ct0_val
444 get_key.assert_called_once_with("Chrome Safe Storage")
445
446 # ---------------------------------------------------------------------------
447 # DB version detection
448 # ---------------------------------------------------------------------------
449
450
451 class TestDbVersion:
452 def test_reads_version_from_meta(self, tmp_path):
453 db_path = str(tmp_path / "test.db")
454 conn = sqlite3.connect(db_path)
455 c = conn.cursor()
456 c.execute("CREATE TABLE meta (key TEXT, value TEXT)")
457 c.execute("INSERT INTO meta VALUES ('version', '24')")
458 conn.commit()
459 assert _get_db_version(c) == 24
460 conn.close()
461
462 def test_no_meta_table_returns_zero(self, tmp_path):
463 db_path = str(tmp_path / "test.db")
464 conn = sqlite3.connect(db_path)
465 c = conn.cursor()
466 c.execute("CREATE TABLE dummy (x TEXT)")
467 conn.commit()
468 assert _get_db_version(c) == 0
469 conn.close()
470
470 lines PYTHON