v0.4.1: Chrome cookie import, config.json, session persistence
CI / test (push) Has been cancelled
style / style (push) Has been cancelled

- 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:
marauder-actual
2026-06-06 16:07:12 +02:00
parent 16bc55bcb9
commit 5e1375f1a1
26 changed files with 1343 additions and 31 deletions
+217
View File
@@ -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