From bfc57d686ac31af21ed0eb893b4ee8a8e1f0d3bf Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 18:22:46 -0700 Subject: [PATCH] [eric] browser: prove the windows cookie crypto layout on mac; make the tested helpers public --- .../apps/onboarding/usage/browser_cookies.py | 14 +- backend/tests/test_browser_cookies_windows.py | 138 ++++++++++++++++++ 2 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 backend/tests/test_browser_cookies_windows.py diff --git a/backend/apps/onboarding/usage/browser_cookies.py b/backend/apps/onboarding/usage/browser_cookies.py index b61eb97f..9f9967cb 100644 --- a/backend/apps/onboarding/usage/browser_cookies.py +++ b/backend/apps/onboarding/usage/browser_cookies.py @@ -64,7 +64,7 @@ p_key_cache: Dict[str, Optional[bytes]] = {} @typechecked -def p_win_dpapi_unprotect(data: bytes) -> Optional[bytes]: +def win_dpapi_unprotect(data: bytes) -> Optional[bytes]: """CryptUnprotectData via crypt32.dll (no pywin32 dependency). None on any failure.""" try: import ctypes @@ -92,7 +92,7 @@ def p_win_dpapi_unprotect(data: bytes) -> Optional[bytes]: @typechecked -def p_win_storage_key(browser: str) -> Optional[bytes]: +def win_storage_key(browser: str) -> Optional[bytes]: """The AES key from a Chromium install's Local State: base64 -> strip 'DPAPI' -> CryptUnprotectData.""" base = CHROMIUM_ROOTS.get(browser) if not base: @@ -104,7 +104,7 @@ def p_win_storage_key(browser: str) -> Optional[bytes]: raw = base64.b64decode(enc_b64) if raw[:5] != b"DPAPI": return None - return p_win_dpapi_unprotect(raw[5:]) + return win_dpapi_unprotect(raw[5:]) except Exception: return None @@ -128,7 +128,7 @@ def p_mac_storage_key(browser: str) -> Optional[bytes]: def p_safe_storage_key(browser: str) -> Optional[bytes]: if browser in p_key_cache: return p_key_cache[browser] - key = p_win_storage_key(browser) if IS_WIN else p_mac_storage_key(browser) + key = win_storage_key(browser) if IS_WIN else p_mac_storage_key(browser) p_key_cache[browser] = key return key @@ -175,7 +175,7 @@ def p_best_store(domain: str) -> Optional[Tuple[str, str]]: @typechecked -def p_decrypt(enc: bytes, key: bytes) -> Optional[str]: +def decrypt_value(enc: bytes, key: bytes) -> Optional[str]: if enc[:3] not in (b"v10", b"v11"): return None # v20 = app-bound encryption, out of reach without the browser try: @@ -228,7 +228,7 @@ def read_provider_cookies(domain: str) -> Dict[str, str]: for name, enc in cur.fetchall(): if not enc: continue - val = p_decrypt(bytes(enc), key) + val = decrypt_value(bytes(enc), key) if val: jar[str(name)] = val con.close() @@ -266,7 +266,7 @@ def read_provider_cookie_records(domain: str) -> List[Dict[str, Any]]: for name, enc, host_key, path, is_secure, is_httponly, expires_utc in cur.fetchall(): if not enc: continue - val = p_decrypt(bytes(enc), key) + val = decrypt_value(bytes(enc), key) if val is None: continue records.append({ diff --git a/backend/tests/test_browser_cookies_windows.py b/backend/tests/test_browser_cookies_windows.py new file mode 100644 index 00000000..a233c3cc --- /dev/null +++ b/backend/tests/test_browser_cookies_windows.py @@ -0,0 +1,138 @@ +"""The Windows cookie path, proven as far as a Mac can prove it. + +browser_cookies.py carries a Windows branch (DPAPI key unwrap, then AES-256-GCM) that has only ever +run on macOS hardware. Its own docstring admits it is written-to-spec and unverified, which is +honest but leaves the riskiest part, the crypto layout, resting on a reading of the Chromium source. + +That part does not actually need Windows. AES-GCM is AES-GCM, and Chromium's on-disk shape is a +fixed byte layout: b"v10" + 12-byte nonce + ciphertext||tag. So this builds a real blob in exactly +that layout and shows the branch recovers the plaintext, which narrows the genuinely unverified +surface down to two things a Mac cannot fake: CryptUnprotectData, and where the files live. + +What still needs a real Windows machine, stated plainly so nobody reads these greens as more than +they are: DPAPI itself, the LOCALAPPDATA store paths, and app-bound (v20) profiles, which are out +of reach on every platform by design. +""" +import base64 +import json +import os + +import pytest + +from backend.apps.onboarding.usage import browser_cookies as bc + +KEY = bytes(range(32)) # AES-256 +NONCE = b"\x00" * 12 + + +def p_win_blob(plaintext: bytes, key: bytes = KEY, prefix: bytes = b"") -> bytes: + """A cookie value encrypted the way Windows Chromium writes it.""" + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + + return b"v10" + NONCE + AESGCM(key).encrypt(NONCE, prefix + plaintext, None) + + +@pytest.fixture +def on_windows(monkeypatch): + monkeypatch.setattr(bc, "IS_WIN", True) + + +# --- the crypto layout ----------------------------------------------------------------------- + +def test_a_real_windows_blob_decrypts(on_windows): + """The whole point: the byte offsets in the GCM branch are right.""" + assert bc.decrypt_value(p_win_blob(b"sessionid=abc123"), KEY) == "sessionid=abc123" + + +def test_the_32_byte_domain_hash_prefix_is_stripped(on_windows): + """Newer Chromium prepends a 32-byte domain hash. Miss it and every cookie value is silently + corrupted at the front, which would send a mangled session cookie rather than fail loudly.""" + assert bc.decrypt_value(p_win_blob(b"tok=xyz", prefix=b"\xa5" * 32), KEY) == "tok=xyz" + + +def test_a_wrong_key_returns_none_instead_of_raising(on_windows): + """A DPAPI key from the wrong profile must degrade, not crash the import.""" + assert bc.decrypt_value(p_win_blob(b"secret"), bytes(32)) is None + + +def test_app_bound_v20_is_refused_on_windows_too(on_windows): + """v20 is app-bound encryption; it is out of reach and must never be half-decoded.""" + assert bc.decrypt_value(b"v20" + NONCE + b"whatever", KEY) is None + + +def test_truncated_ciphertext_degrades(on_windows): + assert bc.decrypt_value(b"v10" + NONCE, KEY) is None + + +def test_the_mac_branch_does_not_try_gcm(): + """Same bytes, no Windows flag: the CBC branch must not accidentally accept a GCM blob.""" + assert bc.decrypt_value(p_win_blob(b"sessionid=abc123"), KEY) is None + + +# --- the key unwrap, minus DPAPI itself ------------------------------------------------------ + +def test_local_state_key_is_base64_decoded_and_the_dpapi_magic_stripped(monkeypatch, tmp_path): + """Everything up to the CryptUnprotectData call is plain parsing and can be checked here: the + key must arrive base64-decoded with the 5-byte b'DPAPI' prefix removed.""" + (tmp_path / "Local State").write_text(json.dumps( + {"os_crypt": {"encrypted_key": base64.b64encode(b"DPAPI" + b"\x11" * 32).decode()}})) + monkeypatch.setitem(bc.CHROMIUM_ROOTS, "Chrome", str(tmp_path)) + + seen = [] + + def fake_unprotect(blob: bytes) -> bytes: + seen.append(blob) + return KEY + + monkeypatch.setattr(bc, "win_dpapi_unprotect", fake_unprotect) + + assert bc.win_storage_key("Chrome") == KEY + assert seen == [b"\x11" * 32], "the DPAPI magic must be stripped before unwrapping" + + +def test_dpapi_failure_yields_no_key_rather_than_an_exception(monkeypatch, tmp_path): + """Wrong user account, roamed profile, corrupted blob: all of it must fail open.""" + (tmp_path / "Local State").write_text(json.dumps( + {"os_crypt": {"encrypted_key": base64.b64encode(b"DPAPI" + b"\x11" * 32).decode()}})) + monkeypatch.setitem(bc.CHROMIUM_ROOTS, "Chrome", str(tmp_path)) + monkeypatch.setattr(bc, "win_dpapi_unprotect", lambda d: None) + + assert bc.win_storage_key("Chrome") is None + + +def test_a_non_dpapi_local_state_is_refused(monkeypatch, tmp_path): + """Chrome on macOS writes a key with no DPAPI prefix; handing that to CryptUnprotectData is + nonsense, so the magic check is what keeps the platforms from crossing.""" + (tmp_path / "Local State").write_text(json.dumps( + {"os_crypt": {"encrypted_key": base64.b64encode(b"v10" + b"\x11" * 32).decode()}})) + monkeypatch.setitem(bc.CHROMIUM_ROOTS, "Chrome", str(tmp_path)) + monkeypatch.setattr(bc, "win_dpapi_unprotect", lambda d: KEY) + + assert bc.win_storage_key("Chrome") is None + + +def test_a_missing_local_state_degrades(monkeypatch, tmp_path): + monkeypatch.setitem(bc.CHROMIUM_ROOTS, "Chrome", str(tmp_path / "nope")) + assert bc.win_storage_key("Chrome") is None + + +def test_an_unknown_browser_has_no_key(): + assert bc.win_storage_key("Netscape") is None + + +def test_windows_stores_are_rooted_in_localappdata(): + """Not a behaviour test, a documentation lock: if someone re-points the Windows roots at the + macOS Application Support layout, every Windows user silently gets the scan floor instead.""" + src = bc.__file__ + with open(src) as f: + text = f.read() + assert "LOCALAPPDATA" in text + assert 'os.path.expanduser("~/AppData/Local")' in text, "the LOCALAPPDATA fallback went missing" + + +def test_the_module_still_imports_on_this_platform(): + """ctypes/wintypes only exist on Windows; the import must stay inside the function so the whole + onboarding package does not explode on macOS.""" + assert callable(bc.win_dpapi_unprotect) + assert bc.win_dpapi_unprotect(b"not really a blob") is None + assert os.path.basename(bc.__file__) == "browser_cookies.py"