5e1375f1a1
- Add Chrome cookie importer (macOS Keychain AES-128-CBC decryption) - Add ~/.config/browse/config.json (headless, fullscreen, stealth, preview defaults) - Add ~/.config/browse/session.json (auto-save/restore cookies + localStorage) - Auto-import from all browsers (Safari + Firefox + Chrome) on first launch - Deduplicate cookies across browsers (domain+name+path key) - Defaults: headed, fullscreen, stealth, preview all enabled - Fix BigInt overflow for Chrome timestamps (CAST to TEXT in SQL)
217 lines
7.4 KiB
JavaScript
217 lines
7.4 KiB
JavaScript
/**
|
|
* 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
|