返回 DeepSeek-Reasonix
chromeImport.test.ts
根目录 / desktop / electron / src / main / chromeImport.test.ts
1 import assert from "node:assert/strict";
2 import { createCipheriv, createHash, pbkdf2Sync, randomBytes } from "node:crypto";
3 import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { DatabaseSync } from "node:sqlite";
7 import { test } from "node:test";
8 import {
9 ChromeImportError,
10 chromeExpiryToUnixSeconds,
11 chromeProfileRoot,
12 chromeSameSite,
13 cookieURL,
14 decryptCBC,
15 decryptGCM,
16 findChromeProfiles,
17 importChromeCookies,
18 readChromeSafeStoragePassword,
19 stripDomainHash,
20 windowsMasterKeyBlob,
21 type ChromeCookie,
22 type ChromeImportDeps,
23 } from "./chromeImport.js";
24
25 const CHROME_EPOCH_OFFSET_SECONDS = 11_644_473_600;
26 const PASSWORD = "test-safe-storage";
27
28 // Chrome 96+ writes SHA-256(host_key) ahead of the value, so fixtures use the
29 // same layout the real profile does.
30 function encryptCBC(value: string, hostKey = "", prefix = "v10"): Buffer {
31 const key = pbkdf2Sync(PASSWORD, "saltysalt", 1003, 16, "sha1");
32 const cipher = createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
33 const head = hostKey === "" ? Buffer.alloc(0) : createHash("sha256").update(hostKey).digest();
34 return Buffer.concat([Buffer.from(prefix), cipher.update(Buffer.concat([head, Buffer.from(value, "utf8")])), cipher.final()]);
35 }
36
37 function encryptGCM(value: string, key: Buffer): Buffer {
38 const nonce = randomBytes(12);
39 const cipher = createCipheriv("aes-256-gcm", key, nonce);
40 const body = Buffer.concat([cipher.update(value, "utf8"), cipher.final()]);
41 return Buffer.concat([Buffer.from("v10"), nonce, body, cipher.getAuthTag()]);
42 }
43
44 function writeCookiesDatabase(root: string, profile: string, rows: Record<string, unknown>[]): string {
45 mkdirSync(join(root, profile), { recursive: true });
46 const path = join(root, profile, "Cookies");
47 const database = new DatabaseSync(path);
48 database.exec(`CREATE TABLE cookies (
49 host_key TEXT, name TEXT, encrypted_value BLOB, path TEXT,
50 expires_utc INTEGER, is_secure INTEGER, is_httponly INTEGER, samesite INTEGER)`);
51 const insert = database.prepare("INSERT INTO cookies VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
52 for (const row of rows) {
53 insert.run(
54 row.host_key as string,
55 row.name as string,
56 row.encrypted_value as Uint8Array,
57 row.path as string,
58 row.expires_utc as number,
59 row.is_secure as number,
60 row.is_httponly as number,
61 row.samesite as number,
62 );
63 }
64 database.close();
65 return path;
66 }
67
68 function deps(seen: ChromeCookie[], overrides: Partial<ChromeImportDeps> = {}): ChromeImportDeps {
69 return {
70 platform: "darwin",
71 home: "/Users/example",
72 env: {},
73 cookies: { set: async (cookie) => void seen.push(cookie) },
74 run: async () => PASSWORD,
75 ...overrides,
76 };
77 }
78
79 const FUTURE = ((BigInt(Math.floor(Date.now() / 1000)) + 86_400n + BigInt(CHROME_EPOCH_OFFSET_SECONDS)) * 1_000_000n).toString();
80 const PAST = ((BigInt(Math.floor(Date.now() / 1000)) - 86_400n + BigInt(CHROME_EPOCH_OFFSET_SECONDS)) * 1_000_000n).toString();
81
82 test("profile roots follow the platform conventions", () => {
83 assert.equal(chromeProfileRoot("darwin", "/Users/example", {}), "/Users/example/Library/Application Support/Google/Chrome");
84 assert.equal(chromeProfileRoot("win32", "C:\\Users\\example", { LOCALAPPDATA: "C:\\Users\\example\\AppData\\Local" }), join("C:\\Users\\example\\AppData\\Local", "Google", "Chrome", "User Data"));
85 assert.equal(chromeProfileRoot("linux", "/home/example", {}), "/home/example/.config/google-chrome");
86 });
87
88 test("expiry converts from Chrome's 1601 epoch and treats zero as a session cookie", () => {
89 assert.equal(chromeExpiryToUnixSeconds(0), undefined);
90 assert.equal(chromeExpiryToUnixSeconds("0"), undefined);
91 assert.equal(chromeExpiryToUnixSeconds(""), undefined);
92 // Chrome's microsecond stamps exceed Number.MAX_SAFE_INTEGER, so the exact
93 // string form must survive the conversion.
94 assert.equal(chromeExpiryToUnixSeconds("11644473600000000"), 0);
95 assert.equal(chromeExpiryToUnixSeconds("11644473660000000"), 60);
96 assert.equal(chromeExpiryToUnixSeconds(11_644_473_660_000_000n), 60);
97 });
98
99 test("cookie fields map to Electron's vocabulary", () => {
100 assert.equal(chromeSameSite(-1), "unspecified");
101 assert.equal(chromeSameSite(0), "no_restriction");
102 assert.equal(chromeSameSite(1), "lax");
103 assert.equal(chromeSameSite(2), "strict");
104 assert.equal(chromeSameSite(9), "unspecified");
105 assert.equal(cookieURL(".example.test", "/app", true), "https://example.test/app");
106 assert.equal(cookieURL("example.test", "app", false), "http://example.test/app");
107 });
108
109 test("CBC and GCM payloads round-trip", () => {
110 assert.equal(decryptCBC(encryptCBC("session-token"), PASSWORD, 1003).toString("utf8"), "session-token");
111 const key = randomBytes(32);
112 assert.equal(decryptGCM(encryptGCM("session-token", key), key).toString("utf8"), "session-token");
113 });
114
115 test("the domain hash Chrome binds to each value is stripped", () => {
116 const digest = createHash("sha256").update(".example.test").digest();
117 const bound = Buffer.concat([digest, Buffer.from("token", "utf8")]);
118 assert.equal(stripDomainHash(bound, ".example.test").toString("utf8"), "token");
119 // A value bound to another host, a short value and an unbound value all
120 // survive untouched.
121 assert.deepEqual(stripDomainHash(bound, ".other.test"), bound);
122 assert.equal(stripDomainHash(Buffer.from("short"), ".example.test").toString("utf8"), "short");
123 assert.equal(stripDomainHash(decryptCBC(encryptCBC("unbound"), PASSWORD, 1003), ".example.test").toString("utf8"), "unbound");
124 });
125
126 test("windows master key strips the DPAPI prefix", () => {
127 const blob = Buffer.concat([Buffer.from("DPAPI"), Buffer.from("secret")]);
128 assert.equal(windowsMasterKeyBlob({ os_crypt: { encrypted_key: blob.toString("base64") } }).toString(), "secret");
129 assert.throws(() => windowsMasterKeyBlob({ os_crypt: {} }), (error: unknown) => error instanceof ChromeImportError && error.code === "safe-storage-unavailable");
130 });
131
132 test("the newest profile with cookies wins", () => {
133 const root = mkdtempSync(join(tmpdir(), "reasonix-chrome-"));
134 writeCookiesDatabase(root, "Default", []);
135 writeCookiesDatabase(root, "Profile 1", []);
136 mkdirSync(join(root, "Crashpad"), { recursive: true });
137 const profiles = findChromeProfiles(root, (path) => ["Default", "Profile 1", "Crashpad"].filter((name) => name !== "" && (name === "Crashpad" || path === root)));
138 assert.deepEqual(profiles.map((profile) => profile.name).sort(), ["Default", "Profile 1"]);
139 assert.deepEqual(findChromeProfiles(join(root, "missing"), () => []), []);
140 });
141
142 test("imports decryptable cookies and skips the ones it cannot use", async () => {
143 const root = mkdtempSync(join(tmpdir(), "reasonix-chrome-"));
144 writeCookiesDatabase(root, "Default", [
145 { host_key: ".example.test", name: "sid", encrypted_value: encryptCBC("token-1", ".example.test"), path: "/", expires_utc: FUTURE, is_secure: 1, is_httponly: 1, samesite: 1 },
146 { host_key: "expired.test", name: "old", encrypted_value: encryptCBC("token-2", "expired.test"), path: "/", expires_utc: PAST, is_secure: 0, is_httponly: 0, samesite: -1 },
147 { host_key: "bound.test", name: "appbound", encrypted_value: encryptCBC("token-3", "bound.test", "v20"), path: "/", expires_utc: FUTURE, is_secure: 1, is_httponly: 0, samesite: 0 },
148 { host_key: "broken.test", name: "garbled", encrypted_value: Buffer.from("v10notreallyencrypted"), path: "/", expires_utc: FUTURE, is_secure: 0, is_httponly: 0, samesite: -1 },
149 ]);
150 const seen: ChromeCookie[] = [];
151 const profiles = findChromeProfiles(root, () => ["Default"]);
152 const summary = await importChromeCookies(deps(seen), root, profiles);
153 assert.deepEqual(summary, { profile: "Default", cookies: 1, skipped: 3 });
154 assert.deepEqual(seen, [
155 {
156 url: "https://example.test/",
157 name: "sid",
158 value: "token-1",
159 domain: ".example.test",
160 path: "/",
161 secure: true,
162 httpOnly: true,
163 sameSite: "lax",
164 expirationDate: Number(BigInt(FUTURE) / 1_000_000n) - CHROME_EPOCH_OFFSET_SECONDS,
165 },
166 ]);
167 });
168
169 test("a rejected cookie is skipped instead of failing the import", async () => {
170 const root = mkdtempSync(join(tmpdir(), "reasonix-chrome-"));
171 writeCookiesDatabase(root, "Default", [
172 { host_key: "example.test", name: "sid", encrypted_value: encryptCBC("token", "example.test"), path: "/", expires_utc: FUTURE, is_secure: 1, is_httponly: 0, samesite: -1 },
173 ]);
174 const summary = await importChromeCookies(
175 deps([], { cookies: { set: async () => Promise.reject(new Error("invalid cookie")) } }),
176 root,
177 findChromeProfiles(root, () => ["Default"]),
178 );
179 assert.deepEqual(summary, { profile: "Default", cookies: 0, skipped: 1 });
180 });
181
182 test("import failures surface as typed codes", async () => {
183 const root = mkdtempSync(join(tmpdir(), "reasonix-chrome-"));
184 await assert.rejects(
185 () => importChromeCookies(deps([]), root, []),
186 (error: unknown) => error instanceof ChromeImportError && error.code === "profile-not-found",
187 );
188 writeCookiesDatabase(root, "Default", []);
189 writeFileSync(join(root, "Default", "Cookies"), "not a database");
190 await assert.rejects(
191 () => importChromeCookies(deps([]), root, findChromeProfiles(root, () => ["Default"])),
192 (error: unknown) => error instanceof ChromeImportError && error.code === "cookies-unreadable",
193 );
194 });
195
196 test("a denied keychain prompt is distinguishable from a missing key", async () => {
197 await assert.rejects(
198 () => readChromeSafeStoragePassword({ platform: "darwin", run: async () => Promise.reject(new Error("User interaction is not allowed.")) }),
199 (error: unknown) => error instanceof ChromeImportError && error.code === "safe-storage-denied",
200 );
201 await assert.rejects(
202 () => readChromeSafeStoragePassword({ platform: "darwin", run: async () => "" }),
203 (error: unknown) => error instanceof ChromeImportError && error.code === "safe-storage-unavailable",
204 );
205 assert.equal(await readChromeSafeStoragePassword({ platform: "linux", run: async () => "" }), "peanuts");
206 await assert.rejects(
207 () => readChromeSafeStoragePassword({ platform: "win32", run: async () => "" }),
208 (error: unknown) => error instanceof ChromeImportError && error.code === "unsupported-platform",
209 );
210 });
211
211 lines TYPESCRIPT