返回 DeepSeek-Reasonix
chromeImport.ts
根目录 / desktop / electron / src / main / chromeImport.ts
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
274 lines TYPESCRIPT