mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-22 04:32:22 +02:00
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""Read the user's own logged-in provider cookies from their real browser, so
|
|
onboarding can harvest their actual chat history at first run without an in-app login.
|
|
|
|
Chromium (Chrome/Arc/Brave/Edge) on macOS AND Windows. We first find WHICH store holds the
|
|
session by counting cookie names in the SQLite (no decryption, no keychain/DPAPI), then decrypt
|
|
only that one store, so the secret key is fetched at most once per browser (cached for the
|
|
process). Per-OS decryption:
|
|
- macOS: "Safe Storage" keychain password -> PBKDF2 -> AES-CBC (v10/v11).
|
|
- Windows: DPAPI-unwrapped key from Local State -> AES-256-GCM (v10/v11).
|
|
v20 = app-bound encryption (modern Chrome), out of reach on both without the browser's own
|
|
elevation service. Fails open to {} on anything (no browser, app-bound cookies, denied
|
|
keychain/DPAPI, Safari-only user), so prep just falls back to the local scan.
|
|
|
|
Only ever reads the specific provider domain asked for; never a general cookie sweep.
|
|
The values are session secrets: used in-process for the harvest, never logged or stored.
|
|
|
|
NOTE: the Windows path is written to the well-documented Chromium/DPAPI scheme but is NOT
|
|
live-tested from this repo's dev machine (macOS); the macOS path is live-proven (490 real
|
|
Claude convos). Both fail open, so a Windows decryption miss degrades to the scan, never crashes.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import shutil
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from typeguard import typechecked
|
|
|
|
IS_WIN = sys.platform == "win32"
|
|
|
|
# Per-OS "User Data" roots (relative to the home dir), where profiles + Local State live.
|
|
if IS_WIN:
|
|
p_local = os.environ.get("LOCALAPPDATA", os.path.expanduser("~/AppData/Local"))
|
|
CHROMIUM_ROOTS = {
|
|
"Chrome": os.path.join(p_local, "Google", "Chrome", "User Data"),
|
|
"Arc": os.path.join(p_local, "Packages"), # Arc/Windows is UWP-packaged + rare; best-effort
|
|
"Brave": os.path.join(p_local, "BraveSoftware", "Brave-Browser", "User Data"),
|
|
"Edge": os.path.join(p_local, "Microsoft", "Edge", "User Data"),
|
|
}
|
|
else:
|
|
p_home = os.path.expanduser("~")
|
|
CHROMIUM_ROOTS = {
|
|
"Chrome": os.path.join(p_home, "Library/Application Support/Google/Chrome"),
|
|
"Arc": os.path.join(p_home, "Library/Application Support/Arc/User Data"),
|
|
"Brave": os.path.join(p_home, "Library/Application Support/BraveSoftware/Brave-Browser"),
|
|
"Edge": os.path.join(p_home, "Library/Application Support/Microsoft Edge"),
|
|
}
|
|
KEYCHAIN_SERVICE = {
|
|
"Chrome": "Chrome Safe Storage",
|
|
"Arc": "Arc Safe Storage",
|
|
"Brave": "Brave Safe Storage",
|
|
"Edge": "Microsoft Edge Safe Storage",
|
|
}
|
|
PROFILES = ["Default"] + [f"Profile {i}" for i in range(1, 12)]
|
|
|
|
# One key fetch per browser per process; "Always Allow" (mac) / DPAPI (win) then never re-prompts.
|
|
p_key_cache: Dict[str, Optional[bytes]] = {}
|
|
|
|
|
|
@typechecked
|
|
def win_dpapi_unprotect(data: bytes) -> Optional[bytes]:
|
|
"""CryptUnprotectData via crypt32.dll (no pywin32 dependency). None on any failure."""
|
|
if sys.platform != "win32":
|
|
return None
|
|
try:
|
|
import ctypes
|
|
from ctypes import wintypes
|
|
|
|
class DATA_BLOB(ctypes.Structure):
|
|
_fields_ = [("cbData", wintypes.DWORD), ("pbData", ctypes.POINTER(ctypes.c_char))]
|
|
|
|
buf = ctypes.create_string_buffer(data, len(data))
|
|
blob_in = DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
|
|
blob_out = DATA_BLOB()
|
|
ok = ctypes.windll.crypt32.CryptUnprotectData(
|
|
ctypes.byref(blob_in), None, None, None, None, 0, ctypes.byref(blob_out)
|
|
)
|
|
if not ok:
|
|
return None
|
|
n = int(blob_out.cbData)
|
|
out = ctypes.create_string_buffer(n)
|
|
ctypes.memmove(out, blob_out.pbData, n)
|
|
ctypes.windll.kernel32.LocalFree(blob_out.pbData)
|
|
return out.raw
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
@typechecked
|
|
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:
|
|
return None
|
|
local_state = os.path.join(base, "Local State")
|
|
try:
|
|
with open(local_state, "r", encoding="utf-8") as f:
|
|
enc_b64 = json.load(f)["os_crypt"]["encrypted_key"]
|
|
raw = base64.b64decode(enc_b64)
|
|
if raw[:5] != b"DPAPI":
|
|
return None
|
|
return win_dpapi_unprotect(raw[5:])
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
@typechecked
|
|
def p_mac_storage_key(browser: str) -> Optional[bytes]:
|
|
try:
|
|
r = subprocess.run(
|
|
["security", "find-generic-password", "-w", "-s", KEYCHAIN_SERVICE[browser]],
|
|
capture_output=True, text=True, timeout=20,
|
|
)
|
|
pw = r.stdout.strip()
|
|
if pw:
|
|
return hashlib.pbkdf2_hmac("sha1", pw.encode(), b"saltysalt", 1003, 16)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
@typechecked
|
|
def p_safe_storage_key(browser: str) -> Optional[bytes]:
|
|
if browser in p_key_cache:
|
|
return p_key_cache[browser]
|
|
key = win_storage_key(browser) if IS_WIN else p_mac_storage_key(browser)
|
|
p_key_cache[browser] = key
|
|
return key
|
|
|
|
|
|
@typechecked
|
|
def p_count_domain(db_path: str, domain: str) -> int:
|
|
tmp = tempfile.mktemp()
|
|
try:
|
|
shutil.copy2(db_path, tmp)
|
|
con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
|
cur = con.cursor()
|
|
cur.execute("SELECT count(*) FROM cookies WHERE host_key LIKE ?", (f"%{domain}",))
|
|
n = int(cur.fetchone()[0])
|
|
con.close()
|
|
return n
|
|
except Exception:
|
|
return 0
|
|
finally:
|
|
try:
|
|
os.remove(tmp)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
@typechecked
|
|
def p_best_store(domain: str) -> Optional[Tuple[str, str]]:
|
|
"""The (browser, db_path) holding the most cookies for `domain`, found WITHOUT the keychain."""
|
|
best: Optional[Tuple[str, str]] = None
|
|
best_score = (0, -1.0)
|
|
for browser, base in CHROMIUM_ROOTS.items():
|
|
if not os.path.isdir(base):
|
|
continue
|
|
for prof in PROFILES:
|
|
for sub in ("Cookies", "Network/Cookies"):
|
|
path = os.path.join(base, prof, sub)
|
|
if not os.path.isfile(path):
|
|
continue
|
|
n = p_count_domain(path, domain)
|
|
if n:
|
|
score = (n, os.path.getmtime(path))
|
|
if score > best_score:
|
|
best, best_score = (browser, path), score
|
|
return best
|
|
|
|
|
|
@typechecked
|
|
def decrypt_cookie_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:
|
|
if IS_WIN:
|
|
# Windows Chromium: v10/v11 = AES-256-GCM, [3:15]=nonce, tail 16 bytes=tag (bundled with ct).
|
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
|
|
dec = AESGCM(key).decrypt(enc[3:15], enc[15:], None)
|
|
else:
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
|
|
|
c = Cipher(algorithms.AES(key), modes.CBC(b" " * 16), backend=default_backend())
|
|
d = c.decryptor()
|
|
dec = d.update(enc[3:]) + d.finalize()
|
|
dec = dec[: -dec[-1]] # strip PKCS7 padding
|
|
for cut in (0, 32): # newer Chromium prepends a 32-byte domain hash
|
|
try:
|
|
return dec[cut:].decode("utf-8")
|
|
except UnicodeDecodeError:
|
|
continue
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
@typechecked
|
|
def has_store(domain: str) -> bool:
|
|
"""Whether any browser store holds records for `domain`, without decrypting and without touching the keychain. The public shape of the presence check, so callers outside this file never need the store tuple."""
|
|
return p_best_store(domain) is not None
|
|
|
|
|
|
@typechecked
|
|
def read_provider_cookies(domain: str) -> Dict[str, str]:
|
|
"""Decrypted cookie jar for `domain`, from whichever browser store actually has the session. At most one keychain touch (that store's browser), cached for the process."""
|
|
store = p_best_store(domain)
|
|
if store is None:
|
|
return {}
|
|
browser, db_path = store
|
|
key = p_safe_storage_key(browser)
|
|
if key is None:
|
|
return {}
|
|
jar: Dict[str, str] = {}
|
|
tmp = tempfile.mktemp()
|
|
try:
|
|
shutil.copy2(db_path, tmp)
|
|
con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
|
cur = con.cursor()
|
|
cur.execute("SELECT name, encrypted_value FROM cookies WHERE host_key LIKE ?", (f"%{domain}",))
|
|
for name, enc in cur.fetchall():
|
|
if not enc:
|
|
continue
|
|
val = decrypt_cookie_value(bytes(enc), key)
|
|
if val:
|
|
jar[str(name)] = val
|
|
con.close()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
os.remove(tmp)
|
|
except OSError:
|
|
pass
|
|
return jar
|
|
|
|
|
|
@typechecked
|
|
def read_provider_cookie_records(domain: str) -> List[Dict[str, Any]]:
|
|
"""Full cookie records ({name,value,domain,path,secure,httponly,expires_utc}) for `domain`, so Electron's offscreen browser can re-inject the session faithfully and pass Cloudflare with a real Chrome TLS handshake. Same one-store, one-keychain-touch path as read_provider_cookies. `expires_utc` stays in Chromium's own units (microseconds since 1601, 0 = session cookie); whoever needs unix seconds converts."""
|
|
store = p_best_store(domain)
|
|
if store is None:
|
|
return []
|
|
browser, db_path = store
|
|
key = p_safe_storage_key(browser)
|
|
if key is None:
|
|
return []
|
|
records: List[Dict[str, Any]] = []
|
|
tmp = tempfile.mktemp()
|
|
try:
|
|
shutil.copy2(db_path, tmp)
|
|
con = sqlite3.connect(f"file:{tmp}?mode=ro", uri=True)
|
|
cur = con.cursor()
|
|
cur.execute(
|
|
"SELECT name, encrypted_value, host_key, path, is_secure, is_httponly, expires_utc "
|
|
"FROM cookies WHERE host_key LIKE ?",
|
|
(f"%{domain}",),
|
|
)
|
|
for name, enc, host_key, path, is_secure, is_httponly, expires_utc in cur.fetchall():
|
|
if not enc:
|
|
continue
|
|
val = decrypt_cookie_value(bytes(enc), key)
|
|
if val is None:
|
|
continue
|
|
records.append({
|
|
"name": str(name), "value": val, "domain": str(host_key),
|
|
"path": str(path) or "/", "secure": bool(is_secure), "httponly": bool(is_httponly),
|
|
"expires_utc": int(expires_utc or 0),
|
|
})
|
|
con.close()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
os.remove(tmp)
|
|
except OSError:
|
|
pass
|
|
return records
|
|
|
|
|
|
# Gemini authenticates on the parent .google.com SSO domain, not gemini.google.com, so its
|
|
# session lives in these named cookies. We read ONLY these (never the whole google cookie
|
|
# jar) and only to load a Gemini page offscreen, keeping the ChatGPT/Claude trust frame.
|
|
GOOGLE_AUTH_COOKIE_NAMES = {
|
|
"SID", "HSID", "SSID", "APISID", "SAPISID", "SIDCC", "NID",
|
|
"__Secure-1PSID", "__Secure-3PSID", "__Secure-1PSIDTS", "__Secure-3PSIDTS",
|
|
"__Secure-1PSIDCC", "__Secure-3PSIDCC", "__Secure-1PAPISID", "__Secure-3PAPISID",
|
|
}
|
|
|
|
|
|
@typechecked
|
|
def read_google_session_records() -> List[Dict[str, Any]]:
|
|
"""The named Google SSO cookies from .google.com, so the offscreen browser can load Gemini logged in. Scoped to the auth set by name, never a general google-cookie sweep."""
|
|
return [r for r in read_provider_cookie_records(".google.com") if r.get("name") in GOOGLE_AUTH_COOKIE_NAMES]
|
|
|
|
|
|
@typechecked
|
|
def cookie_header(jar: Dict[str, str]) -> str:
|
|
return "; ".join(f"{k}={v}" for k, v in jar.items())
|
|
|
|
|
|
@typechecked
|
|
def logged_in_providers() -> List[str]:
|
|
"""Which providers have a readable session, WITHOUT decrypting or touching the keychain: safe for a UI presence check."""
|
|
out: List[str] = []
|
|
for provider, domain in (("codex", "chatgpt.com"), ("claude", "claude.ai"), ("gemini", "gemini.google.com")):
|
|
if p_best_store(domain) is not None:
|
|
out.append(provider)
|
|
return out
|