/** * Chromium browser cookie import — read and decrypt cookies from real browsers * * Supports macOS and Linux Chromium-based browsers. * Pure logic module — no Playwright dependency, no HTTP concerns. * * Decryption pipeline: * * ┌──────────────────────────────────────────────────────────────────┐ * │ 1. Resolve the cookie DB from the browser profile dir │ * │ - macOS: ~/Library/Application Support// │ * │ - Linux: ~/.config// │ * │ │ * │ 2. Derive the AES key │ * │ - macOS v10: Keychain password, PBKDF2(..., iter=1003) │ * │ - Linux v10: "peanuts", PBKDF2(..., iter=1) │ * │ - Linux v11: libsecret/secret-tool password, iter=1 │ * │ │ * │ 3. For each cookie with encrypted_value starting with "v10"/ │ * │ "v11": │ * │ - 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 of Chromium cookie metadata │ * │ - 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; // primary storage dir (retained for compatibility with existing callers/tests) keychainService: string; aliases: string[]; linuxDataDir?: string; linuxApplication?: string; } export interface ProfileEntry { name: string; // e.g. "Default", "Profile 1", "Profile 3" displayName: string; // human-friendly name from Preferences, or falls back to dir name } 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'; } } type BrowserPlatform = 'darwin' | 'linux'; interface BrowserMatch { browser: BrowserInfo; platform: BrowserPlatform; dbPath: string; } // ─── 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', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome' }, { name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium' }, { name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] }, { name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave' }, { name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-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 in any profile). */ export function findInstalledBrowsers(): BrowserInfo[] { return BROWSER_REGISTRY.filter(browser => { // Check Default profile on any platform if (findBrowserMatch(browser, 'Default') !== null) return true; // Check numbered profiles (Profile 1, Profile 2, etc.) for (const platform of getSearchPlatforms()) { const dataDir = getDataDirForPlatform(browser, platform); if (!dataDir) continue; const browserDir = path.join(getBaseDir(platform), dataDir); try { const entries = fs.readdirSync(browserDir, { withFileTypes: true }); if (entries.some(e => e.isDirectory() && e.name.startsWith('Profile ') && fs.existsSync(path.join(browserDir, e.name, 'Cookies')) )) return true; } catch {} } return false; }); } export function listSupportedBrowserNames(): string[] { const hostPlatform = getHostPlatform(); return BROWSER_REGISTRY .filter(browser => hostPlatform ? getDataDirForPlatform(browser, hostPlatform) !== null : true) .map(browser => browser.name); } /** * List available profiles for a browser. */ export function listProfiles(browserName: string): ProfileEntry[] { const browser = resolveBrowser(browserName); const profiles: ProfileEntry[] = []; // Scan each supported platform for profile directories for (const platform of getSearchPlatforms()) { const dataDir = getDataDirForPlatform(browser, platform); if (!dataDir) continue; const browserDir = path.join(getBaseDir(platform), dataDir); if (!fs.existsSync(browserDir)) continue; let entries: fs.Dirent[]; try { entries = fs.readdirSync(browserDir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isDirectory()) continue; if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue; const cookiePath = path.join(browserDir, entry.name, 'Cookies'); if (!fs.existsSync(cookiePath)) continue; // Avoid duplicates if the same profile appears on multiple platforms if (profiles.some(p => p.name === entry.name)) continue; // Try to read display name from Preferences. // Prefer account email — signed-in Chrome profiles often have generic // names like "Person 2" while the email is far more readable. let displayName = entry.name; try { const prefsPath = path.join(browserDir, entry.name, 'Preferences'); if (fs.existsSync(prefsPath)) { const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8')); const email = prefs?.account_info?.[0]?.email; if (email && typeof email === 'string') { displayName = email; } else { const profileName = prefs?.profile?.name; if (profileName && typeof profileName === 'string') { displayName = profileName; } } } } catch { // Ignore — fall back to directory name } profiles.push({ name: entry.name, displayName }); } // Found profiles on this platform — no need to check others if (profiles.length > 0) break; } return profiles; } /** * 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 match = getBrowserMatch(browser, profile); const db = openDb(match.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 match = getBrowserMatch(browser, profile); const derivedKeys = await getDerivedKeys(match); const db = openDb(match.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, derivedKeys); 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 getHostPlatform(): BrowserPlatform | null { if (process.platform === 'darwin' || process.platform === 'linux') return process.platform; return null; } function getSearchPlatforms(): BrowserPlatform[] { const current = getHostPlatform(); const order: BrowserPlatform[] = []; if (current) order.push(current); for (const platform of ['darwin', 'linux'] as BrowserPlatform[]) { if (!order.includes(platform)) order.push(platform); } return order; } function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null { return platform === 'darwin' ? browser.dataDir : browser.linuxDataDir || null; } function getBaseDir(platform: BrowserPlatform): string { return platform === 'darwin' ? path.join(os.homedir(), 'Library', 'Application Support') : path.join(os.homedir(), '.config'); } function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null { validateProfile(profile); for (const platform of getSearchPlatforms()) { const dataDir = getDataDirForPlatform(browser, platform); if (!dataDir) continue; const dbPath = path.join(getBaseDir(platform), dataDir, profile, 'Cookies'); try { if (fs.existsSync(dbPath)) { return { browser, platform, dbPath }; } } catch {} } return null; } function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch { const match = findBrowserMatch(browser, profile); if (match) return match; const attempted = getSearchPlatforms() .map(platform => { const dataDir = getDataDirForPlatform(browser, platform); return dataDir ? path.join(getBaseDir(platform), dataDir, profile, 'Cookies') : null; }) .filter((entry): entry is string => entry !== null); throw new CookieImportError( `${browser.name} is not installed (no cookie database at ${attempted.join(' or ')})`, 'not_installed', ); } // ─── 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) ───────────── function deriveKey(password: string, iterations: number): Buffer { return crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1'); } function getCachedDerivedKey(cacheKey: string, password: string, iterations: number): Buffer { const cached = keyCache.get(cacheKey); if (cached) return cached; const derived = deriveKey(password, iterations); keyCache.set(cacheKey, derived); return derived; } async function getDerivedKeys(match: BrowserMatch): Promise> { if (match.platform === 'darwin') { const password = await getMacKeychainPassword(match.browser.keychainService); return new Map([ ['v10', getCachedDerivedKey(`darwin:${match.browser.keychainService}:v10`, password, 1003)], ]); } const keys = new Map(); keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1)); const linuxPassword = await getLinuxSecretPassword(match.browser); if (linuxPassword) { keys.set( 'v11', getCachedDerivedKey(`linux:${match.browser.keychainService}:v11`, linuxPassword, 1), ); } return keys; } async function getMacKeychainPassword(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', ); } } async function getLinuxSecretPassword(browser: BrowserInfo): Promise { const attempts: string[][] = [ ['secret-tool', 'lookup', 'Title', browser.keychainService], ]; if (browser.linuxApplication) { attempts.push( ['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password_v2', 'application', browser.linuxApplication], ['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password', 'application', browser.linuxApplication], ); } for (const cmd of attempts) { const password = await runPasswordLookup(cmd, 3_000); if (password) return password; } return null; } async function runPasswordLookup(cmd: string[], timeoutMs: number): Promise { try { const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' }); const timeout = new Promise((_, reject) => setTimeout(() => { proc.kill(); reject(new Error('timeout')); }, timeoutMs), ); const exitCode = await Promise.race([proc.exited, timeout]); const stdout = await new Response(proc.stdout).text(); if (exitCode !== 0) return null; const password = stdout.trim(); return password.length > 0 ? password : null; } catch { return null; } } // ─── 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, keys: Map): 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'); const key = keys.get(prefix); if (!key) throw new Error(`No decryption key available for ${prefix} cookies`); 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()]); // Chromium prefixes encrypted cookie payloads with 32 bytes of metadata. 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'; } }