| 1 | import { DatabaseSync } from "node:sqlite"; |
| 2 | import { execFile } from "node:child_process"; |
| 3 | import { existsSync, readFileSync, statSync } from "node:fs"; |
| 4 | import { join } from "node:path"; |
| 5 | import { createDecipheriv, createHash, pbkdf2Sync } from "node:crypto"; |
| 6 | import type { ChromeImportFailure } from "../shared/ipc.js"; |
| 7 | |
| 8 | export interface ChromeProfile { |
| 9 | name: string; |
| 10 | cookiesPath: string; |
| 11 | modifiedAt: number; |
| 12 | } |
| 13 | |
| 14 | export class ChromeImportError extends Error { |
| 15 | constructor(readonly code: ChromeImportFailure, message?: string) { |
| 16 | super(message ?? code); |
| 17 | this.name = "ChromeImportError"; |
| 18 | } |
| 19 | } |
| 20 | |
| 21 | export interface ChromeImportSummary { |
| 22 | profile: string; |
| 23 | cookies: number; |
| 24 | skipped: number; |
| 25 | } |
| 26 | |
| 27 | export interface ChromeCookie { |
| 28 | url: string; |
| 29 | name: string; |
| 30 | value: string; |
| 31 | domain: string; |
| 32 | path: string; |
| 33 | secure: boolean; |
| 34 | httpOnly: boolean; |
| 35 | sameSite: "unspecified" | "no_restriction" | "lax" | "strict"; |
| 36 | expirationDate?: number; |
| 37 | } |
| 38 | |
| 39 | export interface CookieSink { |
| 40 | set(cookie: ChromeCookie): Promise<void>; |
| 41 | } |
| 42 | |
| 43 | export interface ChromeImportDeps { |
| 44 | platform: NodeJS.Platform; |
| 45 | home: string; |
| 46 | env: NodeJS.ProcessEnv; |
| 47 | cookies: CookieSink; |
| 48 | run(command: string, args: string[]): Promise<string>; |
| 49 | log?: { info(message: string): void; warn(message: string): void }; |
| 50 | } |
| 51 | |
| 52 | const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600; |
| 53 | const CIPHER_ITERATIONS = { darwin: 1003, linux: 1 } as const; |
| 54 | const SAME_SITE = ["no_restriction", "lax", "strict"] as const; |
| 55 | |
| 56 | export function chromeProfileRoot(platform: NodeJS.Platform, home: string, env: NodeJS.ProcessEnv): string { |
| 57 | if (platform === "darwin") return join(home, "Library", "Application Support", "Google", "Chrome"); |
| 58 | if (platform === "win32") { |
| 59 | const local = env.LOCALAPPDATA && env.LOCALAPPDATA !== "" ? env.LOCALAPPDATA : join(home, "AppData", "Local"); |
| 60 | return join(local, "Google", "Chrome", "User Data"); |
| 61 | } |
| 62 | return join(home, ".config", "google-chrome"); |
| 63 | } |
| 64 | |
| 65 | // Chrome writes one Cookies SQLite database per profile; the newest database is |
| 66 | // the profile the user last browsed with, which is the one worth importing. |
| 67 | export function findChromeProfiles(root: string, list: (path: string) => string[]): ChromeProfile[] { |
| 68 | if (!existsSync(root)) return []; |
| 69 | const profiles: ChromeProfile[] = []; |
| 70 | for (const name of list(root)) { |
| 71 | if (name !== "Default" && !/^Profile \d+$/.test(name)) continue; |
| 72 | const cookiesPath = join(root, name, "Cookies"); |
| 73 | if (!existsSync(cookiesPath)) continue; |
| 74 | try { |
| 75 | profiles.push({ name, cookiesPath, modifiedAt: statSync(cookiesPath).mtimeMs }); |
| 76 | } catch { |
| 77 | continue; |
| 78 | } |
| 79 | } |
| 80 | return profiles.sort((left, right) => right.modifiedAt - left.modifiedAt); |
| 81 | } |
| 82 | |
| 83 | export function chromeExpiryToUnixSeconds(expiresUTC: string | number | bigint): number | undefined { |
| 84 | const micros = toBigInt(expiresUTC); |
| 85 | if (micros === null || micros <= 0n) return undefined; |
| 86 | return Number(micros / 1_000_000n) - CHROME_EPOCH_OFFSET_SECONDS; |
| 87 | } |
| 88 | |
| 89 | // Chrome's microsecond timestamps exceed Number.MAX_SAFE_INTEGER, so every path |
| 90 | // keeps them as text or BigInt until the division is already exact. |
| 91 | function toBigInt(value: string | number | bigint): bigint | null { |
| 92 | try { |
| 93 | if (typeof value === "bigint") return value; |
| 94 | if (typeof value === "number") return Number.isFinite(value) ? BigInt(Math.trunc(value)) : null; |
| 95 | return value.trim() === "" ? null : BigInt(value); |
| 96 | } catch { |
| 97 | return null; |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | // Chrome stores sameSite as -1/0/1/2; Electron takes the same names on the wire, |
| 102 | // so this is a rename rather than a translation. |
| 103 | export function chromeSameSite(value: number): ChromeCookie["sameSite"] { |
| 104 | return SAME_SITE[value] ?? "unspecified"; |
| 105 | } |
| 106 | |
| 107 | export function cookieURL(hostKey: string, path: string, secure: boolean): string { |
| 108 | const host = hostKey.startsWith(".") ? hostKey.slice(1) : hostKey; |
| 109 | return `${secure ? "https" : "http"}://${host}${path.startsWith("/") ? path : `/${path}`}`; |
| 110 | } |
| 111 | |
| 112 | // macOS and Linux wrap cookie values with AES-128-CBC under a PBKDF2 key: the |
| 113 | // login-keychain secret on macOS, the well-known "peanuts" password elsewhere. |
| 114 | export function decryptCBC(payload: Buffer, password: string, iterations: number): Buffer { |
| 115 | const prefix = payload.subarray(0, 3).toString("latin1"); |
| 116 | const body = prefix === "v10" || prefix === "v11" ? payload.subarray(3) : payload; |
| 117 | const key = pbkdf2Sync(password, "saltysalt", iterations, 16, "sha1"); |
| 118 | const decipher = createDecipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20)); |
| 119 | return Buffer.concat([decipher.update(body), decipher.final()]); |
| 120 | } |
| 121 | |
| 122 | // Windows keeps a DPAPI-protected master key in Local State and seals each value |
| 123 | // with AES-256-GCM; v20 values are App-Bound and cannot be read here at all. |
| 124 | export function decryptGCM(payload: Buffer, key: Buffer): Buffer { |
| 125 | const decipher = createDecipheriv("aes-256-gcm", key, payload.subarray(3, 15)); |
| 126 | decipher.setAuthTag(payload.subarray(payload.length - 16)); |
| 127 | return Buffer.concat([decipher.update(payload.subarray(15, payload.length - 16)), decipher.final()]); |
| 128 | } |
| 129 | |
| 130 | // Chrome 96+ binds every value to its host: the plaintext starts with the |
| 131 | // 32-byte SHA-256 of host_key before the value itself. |
| 132 | export function stripDomainHash(plaintext: Buffer, hostKey: string): Buffer { |
| 133 | if (plaintext.length <= 32) return plaintext; |
| 134 | const digest = createHash("sha256").update(hostKey).digest(); |
| 135 | return plaintext.subarray(0, 32).equals(digest) ? plaintext.subarray(32) : plaintext; |
| 136 | } |
| 137 | |
| 138 | export function windowsMasterKeyBlob(localState: unknown): Buffer { |
| 139 | const record = typeof localState === "object" && localState !== null ? (localState as Record<string, unknown>) : {}; |
| 140 | const osCrypt = typeof record.os_crypt === "object" && record.os_crypt !== null ? (record.os_crypt as Record<string, unknown>) : {}; |
| 141 | const encoded = osCrypt.encrypted_key; |
| 142 | if (typeof encoded !== "string" || encoded === "") throw new ChromeImportError("safe-storage-unavailable", "chrome_master_key_missing"); |
| 143 | const raw = Buffer.from(encoded, "base64"); |
| 144 | return raw.subarray(0, 5).toString("latin1") === "DPAPI" ? raw.subarray(5) : raw; |
| 145 | } |
| 146 | |
| 147 | export async function readChromeSafeStoragePassword(deps: Pick<ChromeImportDeps, "platform" | "run">): Promise<string> { |
| 148 | if (deps.platform === "linux") return "peanuts"; |
| 149 | if (deps.platform !== "darwin") throw new ChromeImportError("unsupported-platform", "windows cookies use a DPAPI master key"); |
| 150 | try { |
| 151 | const password = (await deps.run("security", ["find-generic-password", "-w", "-s", "Chrome Safe Storage"])).trim(); |
| 152 | if (password === "") throw new ChromeImportError("safe-storage-unavailable", "empty keychain secret"); |
| 153 | return password; |
| 154 | } catch (error) { |
| 155 | if (error instanceof ChromeImportError) throw error; |
| 156 | throw new ChromeImportError("safe-storage-denied", error instanceof Error ? error.message : String(error)); |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | async function readWindowsMasterKey(deps: Pick<ChromeImportDeps, "run">, root: string): Promise<Buffer> { |
| 161 | const localStatePath = join(root, "Local State"); |
| 162 | if (!existsSync(localStatePath)) throw new ChromeImportError("safe-storage-unavailable", "Local State missing"); |
| 163 | let parsed: unknown; |
| 164 | try { |
| 165 | parsed = JSON.parse(readFileSync(localStatePath, "utf8")); |
| 166 | } catch (error) { |
| 167 | throw new ChromeImportError("safe-storage-unavailable", error instanceof Error ? error.message : String(error)); |
| 168 | } |
| 169 | const blob = windowsMasterKeyBlob(parsed).toString("base64"); |
| 170 | const script = [ |
| 171 | "$ErrorActionPreference='Stop'", |
| 172 | `$data=[Convert]::FromBase64String('${blob}')`, |
| 173 | "$plain=[Security.Cryptography.ProtectedData]::Unprotect($data,$null,'CurrentUser')", |
| 174 | "[Convert]::ToBase64String($plain)", |
| 175 | ].join(";"); |
| 176 | const output = await deps.run("powershell", ["-NoProfile", "-NonInteractive", "-Command", script]).catch((error: unknown) => { |
| 177 | throw new ChromeImportError("safe-storage-denied", error instanceof Error ? error.message : String(error)); |
| 178 | }); |
| 179 | return Buffer.from(output.trim(), "base64"); |
| 180 | } |
| 181 | |
| 182 | interface CookieRow { |
| 183 | host_key: string; |
| 184 | name: string; |
| 185 | encrypted_value: Uint8Array; |
| 186 | path: string; |
| 187 | expires_utc: string; |
| 188 | is_secure: number; |
| 189 | is_httponly: number; |
| 190 | samesite: number; |
| 191 | } |
| 192 | |
| 193 | export function readCookieRows(cookiesPath: string): CookieRow[] { |
| 194 | const database = new DatabaseSync(cookiesPath, { readOnly: true }); |
| 195 | try { |
| 196 | return database |
| 197 | .prepare( |
| 198 | "SELECT host_key, name, encrypted_value, path, CAST(expires_utc AS TEXT) AS expires_utc, is_secure, is_httponly, samesite FROM cookies", |
| 199 | ) |
| 200 | .all() as unknown as CookieRow[]; |
| 201 | } finally { |
| 202 | database.close(); |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | export async function importChromeCookies(deps: ChromeImportDeps, root: string, profiles: ChromeProfile[]): Promise<ChromeImportSummary> { |
| 207 | const profile = profiles[0]; |
| 208 | if (!profile) throw new ChromeImportError("profile-not-found"); |
| 209 | let rows: CookieRow[]; |
| 210 | try { |
| 211 | rows = readCookieRows(profile.cookiesPath); |
| 212 | } catch (error) { |
| 213 | throw new ChromeImportError("cookies-unreadable", error instanceof Error ? error.message : String(error)); |
| 214 | } |
| 215 | const password = deps.platform === "win32" ? "" : await readChromeSafeStoragePassword(deps); |
| 216 | const masterKey = deps.platform === "win32" ? await readWindowsMasterKey(deps, root) : null; |
| 217 | const iterations = deps.platform === "linux" ? CIPHER_ITERATIONS.linux : CIPHER_ITERATIONS.darwin; |
| 218 | const now = Math.floor(Date.now() / 1000); |
| 219 | let cookies = 0; |
| 220 | let skipped = 0; |
| 221 | for (const row of rows) { |
| 222 | const expiry = chromeExpiryToUnixSeconds(row.expires_utc); |
| 223 | if (expiry !== undefined && expiry <= now) { |
| 224 | skipped++; |
| 225 | continue; |
| 226 | } |
| 227 | let value: string; |
| 228 | try { |
| 229 | const payload = Buffer.from(row.encrypted_value); |
| 230 | const prefix = payload.subarray(0, 3).toString("latin1"); |
| 231 | if (prefix === "v20") { |
| 232 | skipped++; |
| 233 | continue; |
| 234 | } |
| 235 | const plaintext = masterKey && prefix === "v10" ? decryptGCM(payload, masterKey) : decryptCBC(payload, password, iterations); |
| 236 | value = stripDomainHash(plaintext, row.host_key).toString("utf8"); |
| 237 | } catch (error) { |
| 238 | deps.log?.warn(`chrome cookie ${row.name} could not be decrypted: ${error instanceof Error ? error.message : String(error)}`); |
| 239 | skipped++; |
| 240 | continue; |
| 241 | } |
| 242 | const secure = row.is_secure !== 0; |
| 243 | const cookie: ChromeCookie = { |
| 244 | url: cookieURL(row.host_key, row.path, secure), |
| 245 | name: row.name, |
| 246 | value, |
| 247 | domain: row.host_key, |
| 248 | path: row.path, |
| 249 | secure, |
| 250 | httpOnly: row.is_httponly !== 0, |
| 251 | sameSite: chromeSameSite(row.samesite), |
| 252 | ...(expiry === undefined ? {} : { expirationDate: expiry }), |
| 253 | }; |
| 254 | try { |
| 255 | await deps.cookies.set(cookie); |
| 256 | cookies++; |
| 257 | } catch (error) { |
| 258 | deps.log?.warn(`chrome cookie ${row.name} was rejected: ${error instanceof Error ? error.message : String(error)}`); |
| 259 | skipped++; |
| 260 | } |
| 261 | } |
| 262 | deps.log?.info(`chrome import: profile=${profile.name} cookies=${cookies} skipped=${skipped}`); |
| 263 | return { profile: profile.name, cookies, skipped }; |
| 264 | } |
| 265 | |
| 266 | export function defaultRunCommand(command: string, args: string[]): Promise<string> { |
| 267 | return new Promise((resolve, reject) => { |
| 268 | execFile(command, args, { maxBuffer: 8 * 1024 * 1024 }, (error, stdout) => { |
| 269 | if (error) reject(error); |
| 270 | else resolve(stdout); |
| 271 | }); |
| 272 | }); |
| 273 | } |
| 274 |