Skill Tracer
Skills › gstack › browse › cookie-import-browser.ts

cookie-import-browser.ts

928 lines · browse/src/cookie-import-browser.ts at 3f79d81

1/**
2 * Chromium browser cookie import — read and decrypt cookies from real browsers
3 *
4 * Supports macOS, Linux, and Windows Chromium-based browsers.
5 * Pure logic module — no Playwright dependency, no HTTP concerns.
6 *
7 * Decryption pipeline:
8 *
9 * ┌──────────────────────────────────────────────────────────────────┐
10 * │ 1. Resolve the cookie DB from the browser profile dir │
11 * │ - macOS: ~/Library/Application Support/<browser>/<profile> │
12 * │ - Linux: ~/.config/<browser>/<profile> │
13 * │ - Windows: %LOCALAPPDATA% or %APPDATA% (Opera) /<browser>/ │
14 * │ <profile>/Network/Cookies, falling back to <profile>/Cookies│
15 * │ │
16 * │ 2. Derive the AES key │
17 * │ - macOS v10: Keychain password, PBKDF2(..., iter=1003) │
18 * │ - Linux v10: "peanuts", PBKDF2(..., iter=1) │
19 * │ - Linux v11: libsecret/secret-tool password, iter=1 │
20 * │ - Windows v10: DPAPI-unprotect Local State os_crypt key │
21 * │ - Windows v20 (App-Bound): not decryptable here │
22 * │ │
23 * │ 3. For each cookie with encrypted_value starting with "v10"/ │
24 * │ "v11": │
25 * │ - Ciphertext = encrypted_value[3:] │
26 * │ - IV = 16 bytes of 0x20 (space character) │
27 * │ - Plaintext = AES-128-CBC-decrypt(key, iv, ciphertext) │
28 * │ - Remove PKCS7 padding │
29 * │ - Skip first 32 bytes of Chromium cookie metadata │
30 * │ - Remaining bytes = cookie value (UTF-8) │
31 * │ Windows v10: AES-256-GCM(nonce=ev[3:15], tag=last 16 bytes); │
32 * │ drop a leading SHA-256(host_key) when present (DB meta v24+) │
33 * │ │
34 * │ 4. If encrypted_value is empty but `value` field is set, │
35 * │ use value directly (unencrypted cookie) │
36 * │ │
37 * │ 5. Chromium epoch: microseconds since 1601-01-01 │
38 * │ Unix seconds = (epoch - 11644473600000000) / 1000000 │
39 * │ │
40 * │ 6. sameSite: 0→"None", 1→"Lax", 2→"Strict", else→"Lax" │
41 * └──────────────────────────────────────────────────────────────────┘
42 */
44import { openCookieDatabase } from './cookie-database';
45import * as crypto from 'crypto';
46import * as fs from 'fs';
47import * as path from 'path';
48import * as os from 'os';
49import { isIP } from 'node:net';
51// ─── Types ──────────────────────────────────────────────────────
53export interface BrowserInfo {
54 name: string;
55 dataDir: string | null; // macOS directory; null when that platform has no supported location
56 keychainService: string;
57 aliases: string[];
58 linuxDataDir?: string;
59 linuxApplication?: string;
60 windowsDataDir?: string;
61 windowsDataRoot?: 'local' | 'roaming';
62 windowsNative?: true;
65export interface ProfileEntry {
66 name: string; // e.g. "Default", "Profile 1", "Profile 3"
67 displayName: string; // human-friendly name from Preferences, or falls back to dir name
70export interface DomainEntry {
71 domain: string;
72 count: number;
75export interface ImportResult {
76 cookies: PlaywrightCookie[];
77 count: number;
78 failed: number;
79 domainCounts: Record<string, number>;
80 failureReasons?: Record<string, number>;
83export interface PlaywrightCookie {
84 name: string;
85 value: string;
86 domain: string;
87 path: string;
88 expires: number;
89 secure: boolean;
90 httpOnly: boolean;
91 sameSite: 'Strict' | 'Lax' | 'None';
94export class CookieImportError extends Error {
95 constructor(
96 message: string,
97 public code: string,
98 public action?: 'retry',
99 ) {
100 super(message);
101 this.name = 'CookieImportError';
102 }
105type BrowserPlatform = 'darwin' | 'linux' | 'win32';
107interface BrowserMatch {
108 browser: BrowserInfo;
109 platform: BrowserPlatform;
110 dbPath: string;
113// ─── Browser Registry ───────────────────────────────────────────
114// Hardcoded — NEVER interpolate user input into shell commands.
116const BROWSER_REGISTRY: BrowserInfo[] = [
117 { name: 'Comet', dataDir: 'Comet/', keychainService: 'Comet Safe Storage', aliases: ['comet', 'perplexity'] },
118 { name: 'Chrome', dataDir: 'Google/Chrome/', keychainService: 'Chrome Safe Storage', aliases: ['chrome', 'google-chrome', 'google-chrome-stable'], linuxDataDir: 'google-chrome/', linuxApplication: 'chrome', windowsDataDir: 'Google/Chrome/User Data/', windowsNative: true },
119 { name: 'Chromium', dataDir: 'chromium/', keychainService: 'Chromium Safe Storage', aliases: ['chromium'], linuxDataDir: 'chromium/', linuxApplication: 'chromium', windowsDataDir: 'Chromium/User Data/', windowsNative: true },
120 { name: 'Arc', dataDir: 'Arc/User Data/', keychainService: 'Arc Safe Storage', aliases: ['arc'] },
121 { name: 'Brave', dataDir: 'BraveSoftware/Brave-Browser/', keychainService: 'Brave Safe Storage', aliases: ['brave'], linuxDataDir: 'BraveSoftware/Brave-Browser/', linuxApplication: 'brave', windowsDataDir: 'BraveSoftware/Brave-Browser/User Data/', windowsNative: true },
122 { name: 'Edge', dataDir: 'Microsoft Edge/', keychainService: 'Microsoft Edge Safe Storage', aliases: ['edge'], linuxDataDir: 'microsoft-edge/', linuxApplication: 'microsoft-edge', windowsDataDir: 'Microsoft/Edge/User Data/', windowsNative: true },
123 // Windows-only. Local State sits directly under the browser root in %APPDATA% — no User Data segment.
124 { name: 'Opera', dataDir: null, keychainService: 'Opera Safe Storage', aliases: ['opera'], windowsDataDir: 'Opera Software/Opera Stable/', windowsDataRoot: 'roaming' },
125 { name: 'Opera GX', dataDir: null, keychainService: 'Opera GX Safe Storage', aliases: ['opera-gx', 'operagx'], windowsDataDir: 'Opera Software/Opera GX Stable/', windowsDataRoot: 'roaming' },
126];
128// ─── Key Cache ──────────────────────────────────────────────────
129// Cache derived AES keys per browser. First import per browser does
130// Keychain + PBKDF2. Subsequent imports reuse the cached key.
132const keyCache = new Map<string, Buffer>();
134// ─── Public API ─────────────────────────────────────────────────
136/**
137 * Find which browsers are installed (have a cookie DB on disk in any profile).
138 */
139export function findInstalledBrowsers(): BrowserInfo[] {
140 return BROWSER_REGISTRY.filter(browser => {
141 // Check Default profile on any platform
142 if (findBrowserMatch(browser, 'Default') !== null) return true;
143 // Check numbered profiles (Profile 1, Profile 2, etc.)
144 for (const platform of getSearchPlatforms()) {
145 const dataDir = getDataDirForPlatform(browser, platform);
146 if (!dataDir) continue;
147 const browserDir = path.join(getBaseDir(platform, browser), dataDir);
148 try {
149 const entries = fs.readdirSync(browserDir, { withFileTypes: true });
150 if (entries.some(e => {
151 if (!e.isDirectory() || !e.name.startsWith('Profile ')) return false;
152 return profileCookieCandidates(platform, path.join(browserDir, e.name)).some(candidate => fs.existsSync(candidate));
153 })) return true;
154 } catch {}
155 }
156 return false;
157 });
160export function listSupportedBrowserNames(): string[] {
161 const hostPlatform = getHostPlatform();
162 return BROWSER_REGISTRY
163 .filter(browser => hostPlatform ? getDataDirForPlatform(browser, hostPlatform) !== null : true)
164 .map(browser => browser.name);
167/**
168 * List available profiles for a browser.
169 */
170export function listProfiles(browserName: string): ProfileEntry[] {
171 const browser = resolveBrowserInfo(browserName);
172 const profiles: ProfileEntry[] = [];
174 // Scan each supported platform for profile directories
175 for (const platform of getSearchPlatforms()) {
176 const dataDir = getDataDirForPlatform(browser, platform);
177 if (!dataDir) continue;
178 const browserDir = path.join(getBaseDir(platform, browser), dataDir);
179 if (!fs.existsSync(browserDir)) continue;
181 let profileNames: Record<string, { name?: unknown }> = {};
182 try {
183 profileNames = JSON.parse(fs.readFileSync(path.join(browserDir, 'Local State'), 'utf-8'))?.profile?.info_cache ?? {};
184 } catch {}
186 let entries: fs.Dirent[];
187 try {
188 entries = fs.readdirSync(browserDir, { withFileTypes: true });
189 } catch {
190 continue;
191 }
193 for (const entry of entries) {
194 if (!entry.isDirectory()) continue;
195 if (entry.name !== 'Default' && !entry.name.startsWith('Profile ')) continue;
196 if (!profileCookieCandidates(platform, path.join(browserDir, entry.name)).some(p => fs.existsSync(p))) continue;
198 // Avoid duplicates if the same profile appears on multiple platforms
199 if (profiles.some(p => p.name === entry.name)) continue;
201 // Try to read display name from Preferences.
202 // Prefer account email — signed-in Chrome profiles often have generic
203 // names like "Person 2" while the email is far more readable.
204 let displayName = entry.name;
205 try {
206 const prefsPath = path.join(browserDir, entry.name, 'Preferences');
207 if (fs.existsSync(prefsPath)) {
208 const prefs = JSON.parse(fs.readFileSync(prefsPath, 'utf-8'));
209 const email = prefs?.account_info?.[0]?.email;
210 if (email && typeof email === 'string') {
211 displayName = email;
212 } else {
213 const profileName = prefs?.profile?.name;
214 if (profileName && typeof profileName === 'string') {
215 displayName = profileName;
216 }
217 }
218 }
219 } catch {
220 // Ignore — fall back to directory name
221 }
223 const currentName = profileNames?.[entry.name]?.name;
224 if (typeof currentName === 'string' && currentName.trim()) displayName = currentName.trim();
226 profiles.push({ name: entry.name, displayName });
227 }
229 // Found profiles on this platform — no need to check others
230 if (profiles.length > 0) break;
231 }
233 return profiles.sort((a, b) => a.name === b.name ? 0 : a.name === 'Default' ? -1 : b.name === 'Default' ? 1 : a.name.localeCompare(b.name, 'en', { numeric: true }));
236export function normalizeCookieDomain(domain: string): string {
237 if (typeof domain !== 'string' || !domain || domain.length > 254 || /[\s\/@?#\\*]/.test(domain)) {
238 throw new CookieImportError('Invalid cookie domain', 'bad_request');
239 }
240 let host = domain.replace(/^\./, '').replace(/\.$/, '');
241 if (isIP(host) === 6) host = '[' + host + ']';
242 try {
243 if (host.includes(':') && (!host.startsWith('[') || !host.endsWith(']') || isIP(host.slice(1, -1)) !== 6)) throw new Error();
244 const url = new URL('http://' + host);
245 if (!url.hostname || url.hostname.length > 253 || url.port || url.pathname !== '/' || url.hostname.split('.').some(label => !label)) throw new Error();
246 return url.hostname;
247 } catch {
248 throw new CookieImportError('Invalid cookie domain', 'bad_request');
249 }
252export function cookieDomainMatches(hostname: string, cookieDomain: string): boolean {
253 const host = normalizeCookieDomain(hostname);
254 const domain = normalizeCookieDomain(cookieDomain);
255 const address = isIP(domain.startsWith('[') ? domain.slice(1, -1) : domain);
256 return host === domain || (!address && cookieDomain.startsWith('.') && host.endsWith('.' + domain));
259export async function withCookieReadRetry<T>(operation: () => T | Promise<T>): Promise<T> {
260 for (let attempt = 0; ; attempt++) {
261 try {
262 return await operation();
263 } catch (err: any) {
264 if (attempt < 2 && ['db_locked', 'SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) {
265 await new Promise(resolve => setTimeout(resolve, [150, 500][attempt]));
266 continue;
267 }
268 if (['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) throw new CookieImportError('Cookie database is busy. Close the source browser and retry.', 'db_locked', 'retry');
269 if (err?.code === 'SQLITE_CORRUPT') throw new CookieImportError('Cookie database is corrupt', 'db_corrupt');
270 if (err?.code === 'SQLITE_READONLY') throw new CookieImportError('Cookie database access was denied', 'db_permission');
271 if (typeof err?.code === 'string' && err.code.startsWith('SQLITE_')) throw new CookieImportError('Cookie database could not be read', 'db_read_error');
272 throw err;
273 }
274 }
277/**
278 * List unique cookie domains + counts from a browser's DB. No decryption.
279 */
280export function listDomains(browserName: string, profile = 'Default'): { domains: DomainEntry[]; browser: string } {
281 const browser = resolveBrowserInfo(browserName);
282 const match = getBrowserMatch(browser, profile);
283 const db = openDb(match.dbPath, browser.name);
284 try {
285 const now = chromiumNow();
286 const rows = db.query(
287 `SELECT host_key AS domain, COUNT(*) AS count
288 FROM cookies
289 WHERE has_expires = 0 OR expires_utc > ?
290 GROUP BY host_key
291 ORDER BY count DESC`
292 ).all(now) as DomainEntry[];
293 return { domains: rows, browser: browser.name };
294 } finally {
295 db.close();
296 }
299/**
300 * Decrypt and return Playwright-compatible cookies for specific domains.
301 */
302export async function importCookies(
303 browserName: string,
304 domains: string[],
305 profile = 'Default',
306): Promise<ImportResult> {
307 if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
309 const selectedDomains = [...new Set(domains.flatMap(domain => {
310 const normalized = normalizeCookieDomain(domain);
311 return [normalized, '.' + normalized];
312 }))];
313 const browser = resolveBrowserInfo(browserName);
314 const match = getBrowserMatch(browser, profile);
315 const db = openDb(match.dbPath, browser.name);
317 try {
318 const now = chromiumNow();
319 // Parameterized query — no SQL injection
320 const placeholders = selectedDomains.map(() => '?').join(',');
321 const rows = db.query(
322 `SELECT host_key, name, value, encrypted_value, path, expires_utc,
323 is_secure, is_httponly, has_expires, samesite
324 FROM cookies
325 WHERE host_key IN (${placeholders})
326 AND (has_expires = 0 OR expires_utc > ?)
327 ORDER BY host_key, name`
328 ).all(...selectedDomains, now) as RawCookie[];
330 const needsKey = rows.some(row => !row.value && row.encrypted_value.length > 0 && Buffer.from(row.encrypted_value).subarray(0, 3).toString() !== 'v20');
331 const derivedKeys = needsKey ? await getDerivedKeys(match) : new Map<string, Buffer>();
333 const cookies: PlaywrightCookie[] = [];
334 let failed = 0;
335 const domainCounts: Record<string, number> = Object.create(null);
336 const failureReasons: Record<string, number> = {};
338 for (const row of rows) {
339 try {
340 const value = decryptCookieValue(row, derivedKeys, match.platform);
341 const cookie = toPlaywrightCookie(row, value);
342 cookies.push(cookie);
343 domainCounts[row.host_key] = (domainCounts[row.host_key] || 0) + 1;
344 } catch (err) {
345 failed++;
346 const reason = err instanceof CookieImportError && err.code === 'v20_encryption' ? 'unsupported_encryption' : 'decryption_failed';
347 failureReasons[reason] = (failureReasons[reason] || 0) + 1;
348 }
349 }
351 return { cookies, count: cookies.length, failed, domainCounts, failureReasons };
352 } finally {
353 db.close();
354 }
357/**
358 * Throw the same typed not-installed error an import would raise when the
359 * profile has no cookie database; returns quietly when one exists.
360 */
361export function assertCookieDatabase(browserName: string, profile: string): void {
362 getBrowserMatch(resolveBrowserInfo(browserName), profile);
365// ─── Internal: Browser Resolution ───────────────────────────────
367export function resolveBrowserInfo(nameOrAlias: string): BrowserInfo {
368 const needle = nameOrAlias.toLowerCase().trim();
369 const found = BROWSER_REGISTRY.find(b =>
370 b.aliases.includes(needle) || b.name.toLowerCase() === needle
371 );
372 if (!found) {
373 const supported = BROWSER_REGISTRY.flatMap(b => b.aliases).join(', ');
374 throw new CookieImportError(
375 `Unknown browser '${nameOrAlias}'. Supported on this OS: ${hostBrowserTokens()}. All names: ${supported}`,
376 'unknown_browser',
377 );
378 }
379 return found;
382const PLATFORM_LABELS: Record<BrowserPlatform, string> = { darwin: 'macOS', linux: 'Linux', win32: 'Windows' };
384function joinLabels(labels: string[]): string {
385 return labels.length <= 1 ? labels.join('') : `${labels.slice(0, -1).join(', ')} and ${labels[labels.length - 1]}`;
388function hostBrowserTokens(): string {
389 const host = getHostPlatform();
390 return BROWSER_REGISTRY
391 .filter(browser => !host || getDataDirForPlatform(browser, host) !== null)
392 .map(browser => `${browser.name} (${browser.aliases[0]})`)
393 .join(', ');
396function symbolicLocation(browser: BrowserInfo, platform: BrowserPlatform): string {
397 const dataDir = getDataDirForPlatform(browser, platform)!.replace(/\/$/, '');
398 if (platform === 'darwin') return `~/Library/Application Support/${dataDir}`;
399 if (platform === 'linux') return `~/.config/${dataDir}`;
400 return `${browser.windowsDataRoot === 'roaming' ? '%APPDATA%' : '%LOCALAPPDATA%'}\\${dataDir.replace(/\//g, '\\')}`;
403function profileCookieCandidates(platform: BrowserPlatform, profileDir: string): string[] {
404 // Chrome 80+ on Windows stores cookies under Network/Cookies; fall back to Cookies
405 return platform === 'win32'
406 ? [path.join(profileDir, 'Network', 'Cookies'), path.join(profileDir, 'Cookies')]
407 : [path.join(profileDir, 'Cookies')];
410function validateProfile(profile: string): void {
411 if (/[/\\]|\.\./.test(profile) || /[\x00-\x1f]/.test(profile)) {
412 throw new CookieImportError(
413 `Invalid profile name: '${profile}'`,
414 'bad_request',
415 );
416 }
419function getHostPlatform(): BrowserPlatform | null {
420 const p = process.platform;
421 if (p === 'darwin' || p === 'linux' || p === 'win32') return p as BrowserPlatform;
422 return null;
425function getSearchPlatforms(): BrowserPlatform[] {
426 const current = getHostPlatform();
427 const order: BrowserPlatform[] = [];
428 if (current) order.push(current);
429 for (const platform of ['darwin', 'linux', 'win32'] as BrowserPlatform[]) {
430 if (!order.includes(platform)) order.push(platform);
431 }
432 return order;
435function getDataDirForPlatform(browser: BrowserInfo, platform: BrowserPlatform): string | null {
436 if (platform === 'darwin') return browser.dataDir || null;
437 if (platform === 'linux') return browser.linuxDataDir || null;
438 return browser.windowsDataDir || null;
441function windowsBaseDir(browser: BrowserInfo): string {
442 if (browser.windowsDataRoot !== 'roaming') return path.join(os.homedir(), 'AppData', 'Local');
443 const appData = process.env.APPDATA;
444 if (typeof appData === 'string' && path.win32.isAbsolute(appData.trim())) return appData.trim();
445 return path.join(os.homedir(), 'AppData', 'Roaming');
448function getBaseDir(platform: BrowserPlatform, browser: BrowserInfo): string {
449 if (platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support');
450 if (platform === 'win32') return windowsBaseDir(browser);
451 return path.join(os.homedir(), '.config');
454function findBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch | null {
455 validateProfile(profile);
456 for (const platform of getSearchPlatforms()) {
457 const dataDir = getDataDirForPlatform(browser, platform);
458 if (!dataDir) continue;
459 const baseProfile = path.join(getBaseDir(platform, browser), dataDir, profile);
460 for (const dbPath of profileCookieCandidates(platform, baseProfile)) {
461 try {
462 if (fs.existsSync(dbPath)) {
463 return { browser, platform, dbPath };
464 }
465 } catch {}
466 }
467 }
468 return null;
471function getBrowserMatch(browser: BrowserInfo, profile: string): BrowserMatch {
472 const match = findBrowserMatch(browser, profile);
473 if (match) return match;
475 const platforms = getSearchPlatforms().filter(platform => getDataDirForPlatform(browser, platform) !== null);
476 const rootFor = (platform: BrowserPlatform) => path.join(getBaseDir(platform, browser), getDataDirForPlatform(browser, platform)!);
477 const checked = `Checked: ${platforms.flatMap(platform => profileCookieCandidates(platform, path.join(rootFor(platform), profile))).join(', ')}.`;
478 const host = getHostPlatform();
480 if (host && !platforms.includes(host)) {
481 const locations = platforms.map(platform => `${symbolicLocation(browser, platform)} on ${PLATFORM_LABELS[platform]}`).join('; ');
482 throw new CookieImportError(
483 `${browser.name} cookie import is available on ${joinLabels(platforms.map(platform => PLATFORM_LABELS[platform]))} only. It reads ${locations}. Browsers available on this OS: ${hostBrowserTokens()}. ${checked}`,
484 'not_installed',
485 );
486 }
488 if (platforms.some(platform => fs.existsSync(rootFor(platform)))) {
489 const available = listProfiles(browser.name).map(entry => entry.name);
490 throw new CookieImportError(
491 `${browser.name} profile '${profile}' not found. Available: ${available.length ? available.join(', ') : 'none'}. ${checked}`,
492 'not_installed',
493 );
494 }
496 const layout = browser.windowsDataRoot === 'roaming'
497 ? ` Supported layout: ${symbolicLocation(browser, 'win32')}\\<Default|Profile N>\\Network\\Cookies. Legacy root-level layouts, side profiles and portable installs are not supported; sign in manually with \`$B handoff\` instead.`
498 : '';
499 throw new CookieImportError(`No supported ${browser.name} cookie database found. ${checked}${layout}`, 'not_installed');
502// ─── Internal: SQLite Access ────────────────────────────────────
504function openDb(dbPath: string, browserName: string): ReturnType<typeof openCookieDatabase> {
505 // On Windows, Chrome holds exclusive WAL locks even when we open readonly.
506 // The readonly open may "succeed" but return empty results because the WAL
507 // (where all actual data lives) can't be replayed. Always use the copy
508 // approach on Windows so we can open read-write and process the WAL.
509 if (process.platform === 'win32') {
510 return openDbFromCopy(dbPath, browserName);
511 }
512 try {
513 return openCookieDatabase(dbPath);
514 } catch (err: any) {
515 if (err?.code === 'sqlite_unavailable') throw new CookieImportError(err.message, 'sqlite_unavailable');
516 if (['SQLITE_BUSY', 'SQLITE_LOCKED'].includes(err?.code)) {
517 return openDbFromCopy(dbPath, browserName);
518 }
519 if (err?.code === 'SQLITE_CORRUPT') {
520 throw new CookieImportError(
521 `Cookie database for ${browserName} is corrupt`,
522 'db_corrupt',
523 );
524 }
525 throw new CookieImportError('Cookie database could not be read', 'db_read_error');
526 }
529function openDbFromCopy(dbPath: string, browserName: string): ReturnType<typeof openCookieDatabase> {
530 // Use os.tmpdir() instead of hardcoded /tmp for cross-platform support (#708)
531 const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-cookies-'));
532 const tmpPath = path.join(tmpDir, 'Cookies');
533 try {
534 fs.chmodSync(tmpDir, 0o700);
535 fs.copyFileSync(dbPath, tmpPath);
536 fs.chmodSync(tmpPath, 0o600);
537 // Also copy WAL and SHM if they exist (for consistent reads)
538 const walPath = dbPath + '-wal';
539 const shmPath = dbPath + '-shm';
540 if (fs.existsSync(walPath)) {
541 fs.copyFileSync(walPath, tmpPath + '-wal');
542 fs.chmodSync(tmpPath + '-wal', 0o600);
543 }
544 if (fs.existsSync(shmPath)) {
545 fs.copyFileSync(shmPath, tmpPath + '-shm');
546 fs.chmodSync(tmpPath + '-shm', 0o600);
547 }
549 const db = openCookieDatabase(tmpPath);
550 // Schedule cleanup after the DB is closed
551 const origClose = db.close.bind(db);
552 db.close = () => {
553 try { origClose(); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }
554 };
555 return db;
556 } catch (err: any) {
557 // Clean up on failure
558 try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {}
559 if (err?.code === 'sqlite_unavailable') throw new CookieImportError(err.message, 'sqlite_unavailable');
560 if (err?.code === 'SQLITE_CORRUPT') throw new CookieImportError('Cookie database is corrupt', 'db_corrupt');
561 if (err?.code === 'EACCES' || err?.code === 'EPERM') throw new CookieImportError('Cookie database access denied', 'db_permission');
562 if (err?.code === 'ENOENT') throw new CookieImportError('Cookie database no longer exists', 'db_missing');
563 if (!/SQLITE_BUSY|SQLITE_LOCKED|database is locked|EBUSY/.test(String(err?.code) + String(err?.message))) {
564 throw new CookieImportError('Cookie database could not be read', 'db_read_error');
565 }
566 throw new CookieImportError(
567 `Cookie database is locked (${browserName} may be running). Try closing ${browserName} first.`,
568 'db_locked',
569 'retry',
570 );
571 }
574// ─── Internal: Keychain Access (async, 10s timeout) ─────────────
576function deriveKey(password: string, iterations: number): Buffer {
577 return crypto.pbkdf2Sync(password, 'saltysalt', iterations, 16, 'sha1');
580function getCachedDerivedKey(cacheKey: string, password: string, iterations: number): Buffer {
581 const cached = keyCache.get(cacheKey);
582 if (cached) return cached;
583 const derived = deriveKey(password, iterations);
584 keyCache.set(cacheKey, derived);
585 return derived;
588async function getDerivedKeys(match: BrowserMatch): Promise<Map<string, Buffer>> {
589 if (match.platform === 'darwin') {
590 const password = await getMacKeychainPassword(match.browser.keychainService);
591 return new Map([
592 ['v10', getCachedDerivedKey(`darwin:${match.browser.keychainService}:v10`, password, 1003)],
593 ]);
594 }
596 if (match.platform === 'win32') {
597 const key = await getWindowsAesKey(match.browser);
598 return new Map([['v10', key]]);
599 }
601 const keys = new Map<string, Buffer>();
602 keys.set('v10', getCachedDerivedKey('linux:v10', 'peanuts', 1));
604 const linuxPassword = await getLinuxSecretPassword(match.browser);
605 if (linuxPassword) {
606 keys.set(
607 'v11',
608 getCachedDerivedKey(`linux:${match.browser.keychainService}:v11`, linuxPassword, 1),
609 );
610 }
611 return keys;
614async function getWindowsAesKey(browser: BrowserInfo): Promise<Buffer> {
615 const cacheKey = `win32:${browser.keychainService}`;
616 const cached = keyCache.get(cacheKey);
617 if (cached) return cached;
619 const platform = 'win32' as const;
620 const dataDir = getDataDirForPlatform(browser, platform);
621 if (!dataDir) throw new CookieImportError(`No Windows data dir for ${browser.name}`, 'not_installed');
623 const localStatePath = path.join(getBaseDir(platform, browser), dataDir, 'Local State');
624 let localState: any;
625 try {
626 localState = JSON.parse(fs.readFileSync(localStatePath, 'utf-8'));
627 } catch (err) {
628 throw new CookieImportError(
629 `Cannot read Local State for ${browser.name}`,
630 'keychain_error',
631 );
632 }
634 const encryptedKeyB64: string = localState?.os_crypt?.encrypted_key;
635 if (!encryptedKeyB64) {
636 throw new CookieImportError(
637 `No encrypted key in Local State for ${browser.name}`,
638 'keychain_not_found',
639 );
640 }
642 // The stored value is base64(b"DPAPI" + dpapi_encrypted_bytes) — strip the 5-byte prefix
643 const encryptedKey = Buffer.from(encryptedKeyB64, 'base64').slice(5);
644 const key = await dpapiDecrypt(encryptedKey);
645 keyCache.set(cacheKey, key);
646 return key;
649async function dpapiDecrypt(encryptedBytes: Buffer): Promise<Buffer> {
650 const script = [
651 'Add-Type -AssemblyName System.Security',
652 '$stdin = [Console]::In.ReadToEnd().Trim()',
653 '$bytes = [System.Convert]::FromBase64String($stdin)',
654 '$dec = [System.Security.Cryptography.ProtectedData]::Unprotect($bytes, $null, [System.Security.Cryptography.DataProtectionScope]::CurrentUser)',
655 'Write-Output ([System.Convert]::ToBase64String($dec))',
656 ].join('; ');
658 const proc = Bun.spawn(['powershell', '-NoProfile', '-Command', script], {
659 windowsHide: true,
660 stdin: 'pipe',
661 stdout: 'pipe',
662 stderr: 'pipe',
663 });
665 try {
666 proc.stdin.write(encryptedBytes.toString('base64'));
667 proc.stdin.end();
668 const { exitCode, stdout } = await readCredentialProcess(proc, 10_000, () =>
669 new CookieImportError('DPAPI decryption timed out', 'keychain_timeout', 'retry'));
670 if (exitCode !== 0) {
671 throw new CookieImportError('DPAPI decryption failed', 'keychain_error');
672 }
673 return Buffer.from(stdout.trim(), 'base64');
674 } catch (err) {
675 if (err instanceof CookieImportError) throw err;
676 throw new CookieImportError(
677 'DPAPI decryption failed',
678 'keychain_error',
679 );
680 }
683async function readCredentialProcess(
684 proc: { exited: Promise<number>; stdout: ReadableStream<Uint8Array>; stderr: ReadableStream<Uint8Array>; kill(): void },
685 timeoutMs: number,
686 timeoutError: () => Error,
687): Promise<{ exitCode: number; stdout: string; stderr: string }> {
688 const readers = [proc.stdout.getReader(), proc.stderr.getReader()];
689 const read = async (reader: ReadableStreamDefaultReader<Uint8Array>): Promise<string> => {
690 const chunks: Uint8Array[] = [];
691 let bytes = 0;
692 for (;;) {
693 const { done, value } = await reader.read();
694 if (done) return Buffer.concat(chunks, bytes).toString('utf8');
695 bytes += value.byteLength;
696 if (bytes > 64 * 1024) throw new Error('Credential process output exceeded the limit');
697 chunks.push(value);
698 }
699 };
700 let timer: ReturnType<typeof setTimeout>;
701 const timeout = new Promise<never>((_, reject) => {
702 timer = setTimeout(() => reject(timeoutError()), timeoutMs);
703 });
704 try {
705 const [exitCode, stdout, stderr] = await Promise.race([
706 Promise.all([proc.exited, read(readers[0]), read(readers[1])]), timeout,
707 ]);
708 return { exitCode, stdout, stderr };
709 } catch (error) {
710 try { proc.kill(); } catch {}
711 for (const reader of readers) {
712 try { void reader.cancel().catch(() => {}); } catch {}
713 }
714 throw error;
715 } finally {
716 clearTimeout(timer!);
717 }
720async function getMacKeychainPassword(service: string): Promise<string> {
721 // Use async Bun.spawn with timeout to avoid blocking the event loop.
722 // macOS may show an Allow/Deny dialog that blocks until the user responds.
723 const proc = Bun.spawn(
724 ['security', 'find-generic-password', '-s', service, '-w'],
725 { stdout: 'pipe', stderr: 'pipe', windowsHide: true },
726 );
728 try {
729 const { exitCode, stdout, stderr } = await readCredentialProcess(proc, 10_000, () =>
730 new CookieImportError(
731 `macOS is waiting for Keychain permission. Look for a dialog asking to allow access to "${service}".`,
732 'keychain_timeout',
733 'retry',
734 ));
736 if (exitCode !== 0) {
737 // Distinguish denied vs not found vs other
738 const errText = stderr.trim().toLowerCase();
739 if (errText.includes('user canceled') || errText.includes('denied') || errText.includes('interaction not allowed')) {
740 throw new CookieImportError(
741 `Keychain access denied. Click "Allow" in the macOS dialog for "${service}".`,
742 'keychain_denied',
743 'retry',
744 );
745 }
746 if (errText.includes('could not be found') || errText.includes('not found')) {
747 throw new CookieImportError(
748 `No Keychain entry for "${service}". Is this a Chromium-based browser?`,
749 'keychain_not_found',
750 );
751 }
752 throw new CookieImportError(
753 'Could not read Keychain',
754 'keychain_error',
755 'retry',
756 );
757 }
759 return stdout.trim();
760 } catch (err) {
761 if (err instanceof CookieImportError) throw err;
762 throw new CookieImportError(
763 'Could not read Keychain',
764 'keychain_error',
765 'retry',
766 );
767 }
770async function getLinuxSecretPassword(browser: BrowserInfo): Promise<string | null> {
771 const attempts: string[][] = [
772 ['secret-tool', 'lookup', 'Title', browser.keychainService],
773 ];
775 if (browser.linuxApplication) {
776 attempts.push(
777 ['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password_v2', 'application', browser.linuxApplication],
778 ['secret-tool', 'lookup', 'xdg:schema', 'chrome_libsecret_os_crypt_password', 'application', browser.linuxApplication],
779 );
780 }
782 for (const cmd of attempts) {
783 const password = await runPasswordLookup(cmd, 3_000);
784 if (password) return password;
785 }
787 return null;
790async function runPasswordLookup(cmd: string[], timeoutMs: number): Promise<string | null> {
791 try {
792 const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe', windowsHide: true });
793 const { exitCode, stdout } = await readCredentialProcess(proc, timeoutMs, () => new Error('timeout'));
794 if (exitCode !== 0) return null;
796 const password = stdout.trim();
797 return password.length > 0 ? password : null;
798 } catch {
799 return null;
800 }
803// ─── Internal: Cookie Decryption ────────────────────────────────
805interface RawCookie {
806 host_key: string;
807 name: string;
808 value: string;
809 encrypted_value: Buffer | Uint8Array;
810 path: string;
811 expires_utc: number | bigint;
812 is_secure: number;
813 is_httponly: number;
814 has_expires: number;
815 samesite: number;
818function decryptCookieValue(row: RawCookie, keys: Map<string, Buffer>, platform: BrowserPlatform): string {
819 // Prefer unencrypted value if present
820 if (row.value && row.value.length > 0) return row.value;
822 const ev = Buffer.from(row.encrypted_value);
823 if (ev.length === 0) return '';
825 const prefix = ev.slice(0, 3).toString('utf-8');
827 // Chrome 127+ on Windows uses App-Bound Encryption (v20) — cannot be decrypted
828 // outside the Chrome process. Caller should fall back to CDP extraction.
829 if (prefix === 'v20') throw new CookieImportError(
830 'Cookie uses App-Bound Encryption (v20). Use CDP extraction instead.',
831 'v20_encryption',
832 );
834 const key = keys.get(prefix);
835 if (!key) throw new Error(`No decryption key available for ${prefix} cookies`);
837 if (platform === 'win32' && prefix === 'v10') {
838 // Windows: AES-256-GCM — structure: v10(3) + nonce(12) + ciphertext + tag(16)
839 const nonce = ev.slice(3, 15);
840 const tag = ev.slice(ev.length - 16);
841 const ciphertext = ev.slice(15, ev.length - 16);
842 const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce) as crypto.DecipherGCM;
843 decipher.setAuthTag(tag);
844 const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
845 // Cookie DB meta version 24+ prefixes the value with SHA-256(host_key).
846 const hostHash = crypto.createHash('sha256').update(row.host_key).digest();
847 const prefixed = plaintext.length >= 32 && plaintext.subarray(0, 32).equals(hostHash);
848 return (prefixed ? plaintext.subarray(32) : plaintext).toString('utf-8');
849 }
851 // macOS / Linux: AES-128-CBC — structure: v10/v11(3) + ciphertext
852 const ciphertext = ev.slice(3);
853 const iv = Buffer.alloc(16, 0x20); // 16 space characters
854 const decipher = crypto.createDecipheriv('aes-128-cbc', key, iv);
855 const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
857 // Chromium prefixes encrypted cookie payloads with 32 bytes of metadata.
858 if (plaintext.length <= 32) return '';
859 return plaintext.slice(32).toString('utf-8');
862function toPlaywrightCookie(row: RawCookie, value: string): PlaywrightCookie {
863 return {
864 name: row.name,
865 value,
866 domain: row.host_key,
867 path: row.path || '/',
868 expires: chromiumEpochToUnix(row.expires_utc, row.has_expires),
869 secure: row.is_secure === 1,
870 httpOnly: row.is_httponly === 1,
871 sameSite: mapSameSite(row.samesite),
872 };
875// ─── Internal: Chromium Epoch Conversion ────────────────────────
877const CHROMIUM_EPOCH_OFFSET = 11644473600000000n;
879function chromiumNow(): bigint {
880 // Current time in Chromium epoch (microseconds since 1601-01-01)
881 return BigInt(Date.now()) * 1000n + CHROMIUM_EPOCH_OFFSET;
884function chromiumEpochToUnix(epoch: number | bigint, hasExpires: number): number {
885 if (hasExpires === 0 || epoch === 0 || epoch === 0n) return -1; // session cookie
886 const epochBig = BigInt(epoch);
887 const unixMicro = epochBig - CHROMIUM_EPOCH_OFFSET;
888 return Number(unixMicro / 1000000n);
891function mapSameSite(value: number): 'Strict' | 'Lax' | 'None' {
892 switch (value) {
893 case 0: return 'None';
894 case 1: return 'Lax';
895 case 2: return 'Strict';
896 default: return 'Lax';
897 }
901export async function importCookiesViaCdp(
902 browserName: string,
903 domains: string[],
904 profile = 'Default',
905): Promise<ImportResult> {
906 if (domains.length === 0) return { cookies: [], count: 0, failed: 0, domainCounts: {} };
907 if (process.platform !== 'win32') throw new CookieImportError('Native extraction is only supported on Windows', 'not_supported');
908 const browser = resolveBrowserInfo(browserName);
909 validateProfile(profile);
910 const dataDir = getDataDirForPlatform(browser, 'win32');
911 if (!dataDir) throw new CookieImportError('This browser is not supported on Windows', 'not_supported');
912 if (!browser.windowsNative) {
913 throw new CookieImportError(`${browser.name} has no native cookie extraction. Sign in manually: run \`$B handoff\`, sign in, then \`$B resume\`.`, 'native_unsupported_browser');
914 }
915 const { importNativeCookies } = await import('./cookie-import-native');
916 const cookies = await importNativeCookies({
917 browserName: browser.name,
918 userDataDir: path.join(getBaseDir('win32', browser), dataDir),
919 profile,
920 domains: [...new Set(domains.flatMap(domain => {
921 const normalized = normalizeCookieDomain(domain);
922 return [normalized, '.' + normalized];
923 }))],
924 });
925 const domainCounts: Record<string, number> = Object.create(null);
926 for (const cookie of cookies) domainCounts[cookie.domain] = (domainCounts[cookie.domain] || 0) + 1;
927 return { cookies, count: cookies.length, failed: 0, domainCounts };