Re-add pi-web-access
This commit is contained in:
321
pi/files/agent/extensions/pi-web-access/chrome-cookies.ts
Normal file
321
pi/files/agent/extensions/pi-web-access/chrome-cookies.ts
Normal file
@@ -0,0 +1,321 @@
|
||||
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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user