/** * Chromium browser cookie import — read and decrypt cookies from real browsers * * Supports macOS Chromium-based browsers: Comet, Chrome, Arc, Brave, Edge. * Pure logic module — no Playwright dependency, no HTTP concerns. * * Decryption pipeline (Chromium macOS "v10" format): * * ┌──────────────────────────────────────────────────────────────────┐ * │ 1. Keychain: `security find-generic-password -s "" -w` │ * │ → base64 password string │ * │ │ * │ 2. Key derivation: │ * │ PBKDF2(password, salt="saltysalt", iter=1003, len=16, sha1) │ * │ → 16-byte AES key │ * │ │ * │ 3. For each cookie with encrypted_value starting with "v10": │ * │ - Ciphertext = encrypted_value[3:] │ * │ - IV = 16 bytes of 0x20 (space character) │ * │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │ * │ - Remove PKCS7 padding │ * │ - Skip first 32 bytes (HMAC-SHA256 authentication tag) │ * │ - Remaining bytes = cookie value (UTF-8) │ * │ │ * │ 4. If encrypted_value is empty but `value` field is set, │ * │ use value directly (unencrypted cookie) │ * │ │ * │ 5. Chromium epoch: microseconds since 1601-01-01 │ * │ Unix seconds = (epoch - 11644473600000000) / 1000000 │ * │ │ * │ 6. sameSite: 0→"None", 1→"Lax", 2→"Strict", else→"Lax" │ * └──────────────────────────────────────────────────────────────────┘ */ import { Database } from 'bun:sqlite'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; // ─── Types ────────────────────────────────────────────────────── export interface BrowserInfo { name: string; dataDir: string; // relative to ~/Library/Application Support/ keychainService: string; aliases: string[]; } export interface DomainEntry { domain: string; count: number; } export interface ImportResult { cookies: PlaywrightCookie[]; count: number; failed: number; domainCounts: Record; } export interface PlaywrightCookie { name: string; value: string; domain: string; path: string; expires: number; secure: boolean; httpOnly: boolean; sameSite: 'Strict' | 'Lax' | 'None'; } export class CookieImportError extends Error { constructor( message: string, public code: string, public action?: 'retry', ) { super(message); this.name = 'CookieImportError'; } } // ─── Browser Registry ─────────────────────────────────────────── // Hardcoded — NEVER interpolate user input into shell commands. const BROWSER_REGISTRY: BrowserInfo[] = [ { name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] }, { name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome'] }, { name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] }, { name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'] }, { name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'] }, ]; // ─── Key Cache ────────────────────────────────────────────────── // Cache derived AES keys per browser. First import per browser does // Keychain + PBKDF2. Subsequent imports reuse the cached key. const keyCache = new Map(); // ─── Public API ───────────────────────────────────────────────── /** * Find which browsers are installed (have a cookie DB on disk). */ export function findInstalledBrowsers(): BrowserInfo[] { const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); return BROWSER_REGISTRY.filter(b => { const dbPath = path.join(appSupport, b.dataDir, 'Default', 'Cookies'); try { return fs.existsSync(dbPath); } catch { return false; } }); } /** * List unique cookie domains + counts from a browser's DB. No decryption. */ export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } { const browser = resolveBrowser(browserName); const dbPath = getCookieDbPath(browser, profile); const db = openDb(dbPath, browser.name); try { const now = chromiumNow(); const rows = db.query( `SELECT host_key AS domain, COUNT(*) AS count FROM cookies WHERE has_expires = 0 OR expires_utc > ? GROUP BY host_key ORDER BY count DESC` ).all(now) as DomainEntry[]; return { domains: rows, browser: browser.name }; } finally { db.close(); } } /** * Decrypt and return Playwright-compatible cookies for specific domains. */ export async function importCookies( browserName: string, domains: string[], profile = 'Default', ): Promise { if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} }; const browser = resolveBrowser(browserName); const derivedKey = await getDerivedKey(browser); const dbPath = getCookieDbPath(browser, profile); const db = openDb(dbPath, browser.name); try { const now = chromiumNow(); // Parameterized query — no SQL injection const placeholders = domains.map(() => '?').join(','); const rows = db.query( `SELECT host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, has_expires, samesite FROM cookies WHERE host_key IN (${placeholders}) AND (has_expires = 0 OR expires_utc > ?) ORDER BY host_key, name` ).all(...domains, now) as RawCookie[]; const cookies: PlaywrightCookie[] = []; let failed = 0; const domainCounts: Record = {}; for (const row of rows) { try { const value = decryptCookieValue(row, derivedKey); const cookie = toPlaywrightCookie(row, value); cookies.push(cookie); domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1; } catch { failed++; } } return { cookies, count: cookies.length, failed, domainCounts }; } finally { db.close(); } } // ─── Internal: Browser Resolution ─────────────────────────────── function resolveBrowser(nameOrAlias: string): BrowserInfo { const needle = nameOrAlias.toLowerCase().trim(); const found = BROWSER_REGISTRY.find(b => b.aliases.includes(needle) || b.name.toLowerCase() === needle ); if (!found) { const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', '); throw new CookieImportError( `Unknown browser '${nameOrAlias}'. Supported: ${supported}`, 'unknown_browser', ); } return found; } function validateProfile(profile: string): void { if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) { throw new CookieImportError( `Invalid profile name: '${profile}'`, 'bad_request', ); } } function getCookieDbPath(browser: BrowserInfo, profile: string): string { validateProfile(profile); const appSupport = path.join(os.homedir(), 'Library', 'Application Support'); const dbPath = path.join(appSupport, browser.dataDir, profile, 'Cookies'); if (!fs.existsSync(dbPath)) { throw new CookieImportError( `${browser.name} is not installed (no cookie database at ${dbPath})`, 'not_installed', ); } return dbPath; } // ─── Internal: SQLite Access ──────────────────────────────────── function openDb(dbPath: string, browserName: string): Database { try { return new Database(dbPath, { readonly: true }); } catch (err: any) { if (err.message?.includes('SQLITE_BUSY') || err.message?.includes('database is locked')) { return openDbFromCopy(dbPath, browserName); } if (err.message?.includes('SQLITE_CORRUPT') || err.message?.includes('malformed')) { throw new CookieImportError( `Cookie database for ${browserName} is corrupt`, 'db_corrupt', ); } throw err; } } function openDbFromCopy(dbPath: string, browserName: string): Database { const tmpPath = `/tmp/browse-cookies-${browserName.toLowerCase()}-${crypto.randomUUID()}.db`; try { fs.copyFileSync(dbPath, tmpPath); // Also copy WAL and SHM if they exist (for consistent reads) const walPath = dbPath + '-wal'; const shmPath = dbPath + '-shm'; if (fs.existsSync(walPath)) fs.copyFileSync(walPath, tmpPath + '-wal'); if (fs.existsSync(shmPath)) fs.copyFileSync(shmPath, tmpPath + '-shm'); const db = new Database(tmpPath, { readonly: true }); // Schedule cleanup after the DB is closed const origClose = db.close.bind(db); db.close = () => { origClose(); try { fs.unlinkSync(tmpPath); } catch {} try { fs.unlinkSync(tmpPath + '-wal'); } catch {} try { fs.unlinkSync(tmpPath + '-shm'); } catch {} }; return db; } catch { // Clean up on failure try { fs.unlinkSync(tmpPath); } catch {} throw new CookieImportError( `Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`, 'db_locked', 'retry', ); } } // ─── Internal: Keychain Access (async, 10s timeout) ───────────── async function getDerivedKey(browser: BrowserInfo): Promise { const cached = keyCache.get(browser.keychainService); if (cached) return cached; const password = await getKeychainPassword(browser.keychainService); const derived = crypto.pbkdf2Sync(password, 'saltysalt', 1003, 16, 'sha1'); keyCache.set(browser.keychainService, derived); return derived; } async function getKeychainPassword(service: string): Promise { // Use async Bun.spawn with timeout to avoid blocking the event loop. // macOS may show an Allow/Deny dialog that blocks until the user responds. const proc = Bun.spawn( ['security', 'find-generic-password', '-s', service, '-w'], { stdout: 'pipe', stderr: 'pipe' }, ); const timeout = new Promise((_, reject) => setTimeout(() => { proc.kill(); reject(new CookieImportError( `macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`, 'keychain_timeout', 'retry', )); }, 10_000), ); try { const exitCode = await Promise.race([proc.exited, timeout]); const stdout = await new Response(proc.stdout).text(); const stderr = await new Response(proc.stderr).text(); if (exitCode !== 0) { // Distinguish denied vs not found vs other const errText = stderr.trim().toLowerCase(); if (errText.includes('user canceled') || errText.includes('denied') || errText.includes('interaction not allowed')) { throw new CookieImportError( `Keychain access denied. Click "Allow" in the macOS dialog for "${service}".`, 'keychain_denied', 'retry', ); } if (errText.includes('could not be found') || errText.includes('not found')) { throw new CookieImportError( `No Keychain entry for "${service}". Is this a Chromium-based browser?`, 'keychain_not_found', ); } throw new CookieImportError( `Could not read Keychain: ${stderr.trim()}`, 'keychain_error', 'retry', ); } return stdout.trim(); } catch (err) { if (err instanceof CookieImportError) throw err; throw new CookieImportError( `Could not read Keychain: ${(err as Error).message}`, 'keychain_error', 'retry', ); } } // ─── Internal: Cookie Decryption ──────────────────────────────── interface RawCookie { host_key: string; name: string; value: string; encrypted_value: Buffer | Uint8Array; path: string; expires_utc: number | bigint; is_secure: number; is_httponly: number; has_expires: number; samesite: number; } function decryptCookieValue(row: RawCookie, key: Buffer): string { // Prefer unencrypted value if present if (row.value && row.value.length > 0) return row.value; const ev = Buffer.from(row.encrypted_value); if (ev.length === 0) return ''; const prefix = ev.slice(0, 3).toString('utf-8'); if (prefix !== 'v10') { throw new Error(`Unknown encryption prefix: ${prefix}`); } const ciphertext = ev.slice(3); const iv = Buffer.alloc(16, 0x20); // 16 space characters const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv); const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); // First 32 bytes are HMAC-SHA256 authentication tag; actual value follows if (plaintext.length <= 32) return ''; return plaintext.slice(32).toString('utf-8'); } function toPlaywrightCookie(row: RawCookie, value: string): PlaywrightCookie { return { name: row.name, value, domain: row.host_key, path: row.path || '/', expires: chromiumEpochToUnix(row.expires_utc, row.has_expires), secure: row.is_secure === 1, httpOnly: row.is_httponly === 1, sameSite: mapSameSite(row.samesite), }; } // ─── Internal: Chromium Epoch Conversion ──────────────────────── const CHROMIUM_EPOCH_OFFSET = 11644473600000000n; function chromiumNow(): bigint { // Current time in Chromium epoch (microseconds since 1601-01-01) return BigInt(Date.now()) * 1000n + CHROMIUM_EPOCH_OFFSET; } function chromiumEpochToUnix(epoch: number | bigint, hasExpires: number): number { if (hasExpires === 0 || epoch === 0 || epoch === 0n) return -1; // session cookie const epochBig = BigInt(epoch); const unixMicro = epochBig - CHROMIUM_EPOCH_OFFSET; return Number(unixMicro / 1000000n); } function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' { switch (value) { case 0: return 'None'; case 1: return 'Lax'; case 2: return 'Strict'; default: return 'Lax'; } }