322 lines
9.2 KiB
TypeScript
322 lines
9.2 KiB
TypeScript
import { execFile } from "node:child_process";
|
|
import { pbkdf2Sync, createDecipheriv } from "node:crypto";
|
|
import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
|
|
import { tmpdir, homedir, platform } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
export type CookieMap = Record<string, string>;
|
|
|
|
const GOOGLE_ORIGINS = [
|
|
"https://gemini.google.com",
|
|
"https://accounts.google.com",
|
|
"https://www.google.com",
|
|
];
|
|
|
|
const ALL_COOKIE_NAMES = new Set([
|
|
"__Secure-1PSID",
|
|
"__Secure-1PSIDTS",
|
|
"__Secure-1PSIDCC",
|
|
"__Secure-1PAPISID",
|
|
"NID",
|
|
"AEC",
|
|
"SOCS",
|
|
"__Secure-BUCKET",
|
|
"__Secure-ENID",
|
|
"SID",
|
|
"HSID",
|
|
"SSID",
|
|
"APISID",
|
|
"SAPISID",
|
|
"__Secure-3PSID",
|
|
"__Secure-3PSIDTS",
|
|
"__Secure-3PAPISID",
|
|
"SIDCC",
|
|
]);
|
|
|
|
function getChromeCookiesPath(): string | null {
|
|
const plat = platform();
|
|
if (plat === "darwin") {
|
|
return join(homedir(), "Library/Application Support/Google/Chrome/Default/Cookies");
|
|
}
|
|
if (plat === "linux") {
|
|
const chromiumPath = join(homedir(), ".config/chromium/Default/Cookies");
|
|
if (existsSync(chromiumPath)) return chromiumPath;
|
|
|
|
const chromePath = join(homedir(), ".config/google-chrome/Default/Cookies");
|
|
if (existsSync(chromePath)) return chromePath;
|
|
|
|
return null;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function getGoogleCookies(): Promise<{ cookies: CookieMap; warnings: string[] } | null> {
|
|
const plat = platform();
|
|
if (plat !== "darwin" && plat !== "linux") return null;
|
|
|
|
const cookiesPath = getChromeCookiesPath();
|
|
if (!cookiesPath) return null;
|
|
|
|
const warnings: string[] = [];
|
|
|
|
const password = await readEncryptionPassword();
|
|
if (!password) {
|
|
warnings.push("Could not read Chrome encryption password");
|
|
return { cookies: {}, warnings };
|
|
}
|
|
|
|
const macKey = plat === "darwin" ? pbkdf2Sync(password, "saltysalt", 1003, 16, "sha1") : Buffer.alloc(0);
|
|
const tempDir = mkdtempSync(join(tmpdir(), "pi-chrome-cookies-"));
|
|
|
|
try {
|
|
const tempDb = join(tempDir, "Cookies");
|
|
copyFileSync(cookiesPath, tempDb);
|
|
copySidecar(cookiesPath, tempDb, "-wal");
|
|
copySidecar(cookiesPath, tempDb, "-shm");
|
|
|
|
const metaVersion = await readMetaVersion(tempDb);
|
|
const stripHash = metaVersion >= 24;
|
|
|
|
const hosts = GOOGLE_ORIGINS.map((o) => new URL(o).hostname);
|
|
const rows = await queryCookieRows(tempDb, hosts);
|
|
if (!rows) {
|
|
warnings.push("Failed to query Chrome cookie database");
|
|
return { cookies: {}, warnings };
|
|
}
|
|
|
|
const cookies: CookieMap = {};
|
|
for (const row of rows) {
|
|
const name = row.name as string;
|
|
if (!ALL_COOKIE_NAMES.has(name)) continue;
|
|
if (cookies[name]) continue;
|
|
|
|
let value = typeof row.value === "string" && row.value.length > 0 ? row.value : null;
|
|
if (!value) {
|
|
const encrypted = row.encrypted_value;
|
|
if (encrypted instanceof Uint8Array) {
|
|
if (plat === "linux") {
|
|
value = decryptLinuxCookieValue(encrypted, password, stripHash);
|
|
} else {
|
|
value = decryptCookieValue(encrypted, macKey, stripHash);
|
|
}
|
|
}
|
|
}
|
|
if (value) cookies[name] = value;
|
|
}
|
|
|
|
return { cookies, warnings };
|
|
} finally {
|
|
rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function decryptCookieValue(encrypted: Uint8Array, key: Buffer, stripHash: boolean): string | null {
|
|
const buf = Buffer.from(encrypted);
|
|
if (buf.length < 3) return null;
|
|
|
|
const prefix = buf.subarray(0, 3).toString("utf8");
|
|
if (!/^v\d\d$/.test(prefix)) return null;
|
|
|
|
const ciphertext = buf.subarray(3);
|
|
if (!ciphertext.length) return "";
|
|
|
|
try {
|
|
const iv = Buffer.alloc(16, 0x20);
|
|
const decipher = createDecipheriv("aes-128-cbc", key, iv);
|
|
decipher.setAutoPadding(false);
|
|
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
const unpadded = removePkcs7Padding(plaintext);
|
|
const bytes = stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded;
|
|
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
let i = 0;
|
|
while (i < decoded.length && decoded.charCodeAt(i) < 0x20) i++;
|
|
return decoded.slice(i);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function decryptLinuxCookieValue(encrypted: Uint8Array, keyringSecret: string, stripHash: boolean): string | null {
|
|
const buf = Buffer.from(encrypted);
|
|
if (buf.length < 3) return null;
|
|
|
|
const prefix = buf.subarray(0, 3).toString("utf8");
|
|
if (prefix !== "v10" && prefix !== "v11") return null;
|
|
|
|
const ciphertext = buf.subarray(3);
|
|
if (!ciphertext.length) return "";
|
|
|
|
try {
|
|
const key = pbkdf2Sync(keyringSecret, "saltysalt", 1, 16, "sha1");
|
|
const iv = Buffer.alloc(16, 0x20);
|
|
const decipher = createDecipheriv("aes-128-cbc", key, iv);
|
|
decipher.setAutoPadding(false);
|
|
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
|
const unpadded = removePkcs7Padding(plaintext);
|
|
const bytes = stripHash && unpadded.length >= 32 ? unpadded.subarray(32) : unpadded;
|
|
const decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
let i = 0;
|
|
while (i < decoded.length && decoded.charCodeAt(i) < 0x20) i++;
|
|
return decoded.slice(i);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function removePkcs7Padding(buf: Buffer): Buffer {
|
|
if (!buf.length) return buf;
|
|
const padding = buf[buf.length - 1];
|
|
if (!padding || padding > 16) return buf;
|
|
return buf.subarray(0, buf.length - padding);
|
|
}
|
|
|
|
function readKeychainPassword(): Promise<string | null> {
|
|
return new Promise((resolve) => {
|
|
execFile(
|
|
"security",
|
|
["find-generic-password", "-w", "-a", "Chrome", "-s", "Chrome Safe Storage"],
|
|
{ timeout: 5000 },
|
|
(err, stdout) => {
|
|
if (err) { resolve(null); return; }
|
|
resolve(stdout.trim() || null);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function readLinuxKeyringPassword(): Promise<string | null> {
|
|
return new Promise((resolve) => {
|
|
execFile(
|
|
"secret-tool",
|
|
["lookup", "application", "chromium"],
|
|
{ timeout: 5000 },
|
|
(err, stdout) => {
|
|
if (!err && stdout.trim()) {
|
|
resolve(stdout.trim());
|
|
return;
|
|
}
|
|
execFile(
|
|
"secret-tool",
|
|
["lookup", "application", "chrome"],
|
|
{ timeout: 5000 },
|
|
(err2, stdout2) => {
|
|
if (!err2 && stdout2.trim()) {
|
|
resolve(stdout2.trim());
|
|
return;
|
|
}
|
|
resolve("peanuts");
|
|
},
|
|
);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
async function readEncryptionPassword(): Promise<string | null> {
|
|
const plat = platform();
|
|
if (plat === "darwin") return readKeychainPassword();
|
|
if (plat === "linux") return readLinuxKeyringPassword();
|
|
return null;
|
|
}
|
|
|
|
let sqliteModule: typeof import("node:sqlite") | null = null;
|
|
|
|
async function importSqlite(): Promise<typeof import("node:sqlite") | null> {
|
|
if (sqliteModule) return sqliteModule;
|
|
const orig = process.emitWarning.bind(process);
|
|
process.emitWarning = ((warning: string | Error, ...args: unknown[]) => {
|
|
const msg = typeof warning === "string" ? warning : warning?.message ?? "";
|
|
if (msg.includes("SQLite is an experimental feature")) return;
|
|
return (orig as Function)(warning, ...args);
|
|
}) as typeof process.emitWarning;
|
|
try {
|
|
sqliteModule = await import("node:sqlite");
|
|
return sqliteModule;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
process.emitWarning = orig;
|
|
}
|
|
}
|
|
|
|
function supportsReadBigInts(): boolean {
|
|
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
if (major > 24) return true;
|
|
if (major < 24) return false;
|
|
return minor >= 4;
|
|
}
|
|
|
|
async function readMetaVersion(dbPath: string): Promise<number> {
|
|
const sqlite = await importSqlite();
|
|
if (!sqlite) return 0;
|
|
const opts: Record<string, unknown> = { readOnly: true };
|
|
if (supportsReadBigInts()) opts.readBigInts = true;
|
|
const db = new sqlite.DatabaseSync(dbPath, opts);
|
|
try {
|
|
const rows = db.prepare("SELECT value FROM meta WHERE key = 'version'").all() as Array<Record<string, unknown>>;
|
|
const val = rows[0]?.value;
|
|
if (typeof val === "number") return Math.floor(val);
|
|
if (typeof val === "bigint") return Number(val);
|
|
if (typeof val === "string") return parseInt(val, 10) || 0;
|
|
return 0;
|
|
} catch {
|
|
return 0;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
async function queryCookieRows(
|
|
dbPath: string,
|
|
hosts: string[],
|
|
): Promise<Array<Record<string, unknown>> | null> {
|
|
const sqlite = await importSqlite();
|
|
if (!sqlite) return null;
|
|
|
|
const clauses: string[] = [];
|
|
for (const host of hosts) {
|
|
for (const candidate of expandHosts(host)) {
|
|
const esc = candidate.replaceAll("'", "''");
|
|
clauses.push(`host_key = '${esc}'`);
|
|
clauses.push(`host_key = '.${esc}'`);
|
|
clauses.push(`host_key LIKE '%.${esc}'`);
|
|
}
|
|
}
|
|
const where = clauses.join(" OR ");
|
|
|
|
const opts: Record<string, unknown> = { readOnly: true };
|
|
if (supportsReadBigInts()) opts.readBigInts = true;
|
|
const db = new sqlite.DatabaseSync(dbPath, opts);
|
|
try {
|
|
return db
|
|
.prepare(
|
|
`SELECT name, value, host_key, encrypted_value FROM cookies WHERE (${where}) ORDER BY expires_utc DESC`,
|
|
)
|
|
.all() as Array<Record<string, unknown>>;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
db.close();
|
|
}
|
|
}
|
|
|
|
function expandHosts(host: string): string[] {
|
|
const parts = host.split(".").filter(Boolean);
|
|
if (parts.length <= 1) return [host];
|
|
const candidates = new Set<string>();
|
|
candidates.add(host);
|
|
for (let i = 1; i <= parts.length - 2; i++) {
|
|
const c = parts.slice(i).join(".");
|
|
if (c) candidates.add(c);
|
|
}
|
|
return Array.from(candidates);
|
|
}
|
|
|
|
function copySidecar(srcDb: string, targetDb: string, suffix: string): void {
|
|
const sidecar = `${srcDb}${suffix}`;
|
|
if (!existsSync(sidecar)) return;
|
|
try {
|
|
copyFileSync(sidecar, `${targetDb}${suffix}`);
|
|
} catch {}
|
|
}
|