/** * Chrome cookie importer (macOS) * * Reads cookies from Chrome's SQLite database and decrypts values using * the encryption key stored in the macOS Keychain ("Chrome Safe Storage"). * * Encryption scheme (macOS): * - Keychain password → PBKDF2(password, salt="saltysalt", iterations=1003, keylen=16) * - AES-128-CBC with IV = 16 bytes of 0x20 (space) * - Encrypted values prefixed with "v10" (3 bytes) * * Chrome timestamps are microseconds since Jan 1, 1601 (Windows/WebKit epoch). * Database is copied to a temp directory to avoid WAL lock conflicts. */ import { execSync } from 'node:child_process'; import { createDecipheriv, pbkdf2Sync } from 'node:crypto'; import { copyFileSync, existsSync, mkdtempSync, readdirSync, rmSync } from 'node:fs'; import { homedir, platform, tmpdir } from 'node:os'; import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; // Chrome epoch (Jan 1, 1601) to Unix epoch (Jan 1, 1970) offset in microseconds const CHROME_EPOCH_OFFSET = 11644473600000000n; /** * Convert Chrome timestamp (microseconds since Jan 1, 1601) to Unix seconds */ function chromeToUnix(chromeTime) { if (chromeTime === 0n) return 0; const unixMicro = chromeTime - CHROME_EPOCH_OFFSET; return Number(unixMicro / 1000000n); } /** * Get Chrome encryption key from macOS Keychain, derive AES key via PBKDF2 */ function getDerivedKey() { if (platform() !== 'darwin') { throw new Error('Chrome cookie decryption is currently only supported on macOS'); } const keychainPassword = execSync('security find-generic-password -s "Chrome Safe Storage" -w', { encoding: 'utf-8', }).trim(); return pbkdf2Sync(keychainPassword, 'saltysalt', 1003, 16, 'sha1'); } /** * Decrypt a Chrome encrypted cookie value */ function decryptValue(encrypted, derivedKey) { if (encrypted.length === 0) return ''; // Check for "v10" prefix (macOS encryption marker) const prefix = encrypted.subarray(0, 3).toString('ascii'); if (prefix !== 'v10') { // Not encrypted or unknown format — return as-is return encrypted.toString('utf-8'); } const ciphertext = encrypted.subarray(3); if (ciphertext.length === 0) return ''; // AES-128-CBC, IV is 16 bytes of 0x20 (space) const iv = Buffer.alloc(16, 0x20); const decipher = createDecipheriv('aes-128-cbc', derivedKey, iv); try { const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); return decrypted.toString('utf-8'); } catch { return ''; } } /** * Get the Chrome user data directory for the current platform */ function getChromeRoot() { const home = homedir(); switch (platform()) { case 'darwin': return join(home, 'Library/Application Support/Google/Chrome'); case 'linux': return join(home, '.config/google-chrome'); case 'win32': return join(process.env.LOCALAPPDATA || join(home, 'AppData/Local'), 'Google/Chrome/User Data'); default: throw new Error(`Unsupported platform: ${platform()}`); } } /** * List available Chrome profiles */ export function listChromeProfiles() { const root = getChromeRoot(); if (!existsSync(root)) return []; const profiles = []; // Check "Default" profile const defaultCookies = join(root, 'Default', 'Cookies'); if (existsSync(defaultCookies)) { profiles.push({ name: 'Default', path: 'Default' }); } // Check numbered profiles (Profile 1, Profile 2, ...) try { const entries = readdirSync(root, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory() && entry.name.startsWith('Profile ')) { const cookiesPath = join(root, entry.name, 'Cookies'); if (existsSync(cookiesPath)) { profiles.push({ name: entry.name, path: entry.name }); } } } } catch { // Ignore readdir errors } return profiles; } /** * Safely copy the Chrome cookies database to a temp directory. * Copies Cookies + WAL + SHM files to avoid lock conflicts. */ function copyDatabaseSafely(dbPath) { if (!existsSync(dbPath)) { throw new Error(`Chrome cookies database not found at: ${dbPath}\nMake sure Chrome has been used at least once.`); } const tmpDir = mkdtempSync(join(tmpdir(), 'browse-cr-')); const dbName = 'Cookies'; try { copyFileSync(dbPath, join(tmpDir, dbName)); for (const ext of ['-wal', '-shm']) { const src = `${dbPath}${ext}`; if (existsSync(src)) { copyFileSync(src, join(tmpDir, `${dbName}${ext}`)); } } return { tmpDir, tmpDbPath: join(tmpDir, dbName) }; } catch (err) { rmSync(tmpDir, { recursive: true, force: true }); throw err; } } /** * Convert Chrome sameSite integer to string * -1 = unspecified (treat as None), 0 = None (was "no_restriction"), 1 = Lax, 2 = Strict */ function sameSiteToString(value) { switch (value) { case 2: return 'Strict'; case 1: return 'Lax'; default: return 'None'; } } /** * Import cookies from Chrome's Cookies SQLite database */ export function importChromeCookies(options) { const root = getChromeRoot(); const profileDir = options?.profile || 'Default'; const dbPath = join(root, profileDir, 'Cookies'); const { tmpDir, tmpDbPath } = copyDatabaseSafely(dbPath); try { const derivedKey = getDerivedKey(); const db = new DatabaseSync(tmpDbPath, { readOnly: true }); let query = 'SELECT name, value, encrypted_value, host_key, path, CAST(expires_utc AS TEXT) as expires_utc_str, is_secure, is_httponly, samesite FROM cookies'; const params = []; if (options?.domain) { const domain = options.domain.toLowerCase(); query += ' WHERE LOWER(host_key) = ? OR LOWER(host_key) = ? OR LOWER(host_key) LIKE ?'; params.push(domain, `.${domain}`, `%.${domain}`); } const stmt = db.prepare(query); const rows = params.length > 0 ? stmt.all(...params) : stmt.all(); db.close(); return rows.map((row) => { // Use plaintext value if available, otherwise decrypt let value = row.value; if (!value && row.encrypted_value) { value = decryptValue(row.encrypted_value, derivedKey); } return { name: row.name, value, domain: row.host_key, path: row.path, expires: chromeToUnix(BigInt(row.expires_utc_str)), secure: row.is_secure === 1, httpOnly: row.is_httponly === 1, sameSite: sameSiteToString(row.samesite), }; }); } finally { rmSync(tmpDir, { recursive: true, force: true }); } } /** * Convert ChromeCookie to Playwright cookie format */ export function toPlaywrightCookie(cookie) { let expires = cookie.expires; if (expires <= 0) { expires = -1; } return { name: cookie.name, value: cookie.value, domain: cookie.domain, path: cookie.path, expires, secure: cookie.secure, httpOnly: cookie.httpOnly, sameSite: cookie.sameSite, }; } //# sourceMappingURL=chrome.js.map