v0.4.1: Chrome cookie import, config.json, session persistence
- 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)
This commit is contained in:
Vendored
+217
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* 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
|
||||
Reference in New Issue
Block a user