Skip to content
Source
Sign inSign up
Open History to see the last commit

ChromiumCookies.test.ts

437 lines20.7 KB

1// @effect-diagnostics nodeBuiltinImport:off - Encrypts fixtures with the same
2// OSCrypt primitives the module under test decrypts.
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { describe, expect, it } from "@effect/vitest";
5import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
6import * as NodeCrypto from "node:crypto";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as SqlClient from "effect/sql/SqlClient";
10​
11import {
12 decryptChromiumValue,
13 readChromiumCookieDatabase,
14 readChromiumCookies,
15} from "./ChromiumCookies.ts";
16import { ChromiumKeyError } from "./ChromiumKeys.ts";
17import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
18import { cookieScope } from "./CookieDatabase.ts";
19​
20const encryptChromium = (
21 prefix: "v10" | "v11",
22 value: string | Buffer,
23 key: Buffer,
24): Uint8Array => {
25 const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20));
26 return Buffer.concat([Buffer.from(prefix), cipher.update(value), cipher.final()]);
27};
28​
29const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array =>
30 encryptChromium("v10", value, key);
31​
32const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => {
33 const nonce = Buffer.from("0123456789ab");
34 const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, nonce);
35 const encrypted = Buffer.concat([cipher.update(value), cipher.final()]);
36 return Buffer.concat([Buffer.from("v10"), nonce, encrypted, cipher.getAuthTag()]);
37};
38​
39describe("cookieScope", () => {
40 it("keeps a host-only cookie host-only", () => {
41 expect(cookieScope("example.test", "/", true)).toEqual({
42 url: "https://example.test/",
43 domain: undefined,
44 });
45 });
46​
47 it("brackets bare IPv6 hosts without duplicating existing brackets", () => {
48 expect(cookieScope("::1", "/", false)).toEqual({
49 url: "http://[::1]/",
50 domain: undefined,
51 });
52 expect(cookieScope("[::1]", "/app", true)).toEqual({
53 url: "https://[::1]/app",
54 domain: undefined,
55 });
56 });
57});
58​
59describe("readChromiumCookieDatabase", () => {
60 it("decrypts Windows v10 AES-GCM records and rejects app-bound v20 records", () => {
61 const key = Buffer.from("0123456789abcdef0123456789abcdef");
62 const host = ".example.test";
63 const bound = Buffer.concat([
64 NodeCrypto.createHash("sha256").update(host).digest(),
65 Buffer.from("windows value"),
66 ]);
67​
68 expect(
69 decryptChromiumValue(encryptWindowsV10(bound, key), { gcmV10: key }, host, 24, "win32"),
70 ).toBe("windows value");
71 expect(
72 decryptChromiumValue(Buffer.from("v20app-bound"), { gcmV10: key }, host, 24, "win32"),
73 ).toBeNull();
74 expect(
75 decryptChromiumValue(
76 encryptWindowsV10(bound, Buffer.alloc(32, 1)),
77 { gcmV10: key },
78 host,
79 24,
80 "win32",
81 ),
82 ).toBeNull();
83 });
84​
85 it.effect(
86 "reports the missing key when no cookies can be read, while preserving partial imports",
87 () =>
88 Effect.gen(function* () {
89 const fileSystem = yield* FileSystem.FileSystem;
90 const directory = yield* fileSystem.makeTempDirectoryScoped({
91 prefix: "t3code-missing-key-",
92 });
93 const filename = `${directory}/Cookies`;
94 const key = Buffer.from("0123456789abcdef");
95 yield* Effect.gen(function* () {
96 const sql = yield* SqlClient.SqlClient;
97 yield* sql`create table meta (key text primary key, value text not null)`;
98 yield* sql`insert into meta values ('version', 23)`;
99 yield* sql`create table cookies (
100 host_key text not null, name text not null, value text not null,
101 encrypted_value blob not null, path text not null, expires_utc integer not null,
102 is_secure integer not null, is_httponly integer not null, samesite integer not null,
103 top_frame_site_key text not null default ''
104 )`;
105 yield* sql`insert into cookies values ('v11.example', 'session', '', ${encryptChromium("v11", "secret", key)}, '/', 0, 1, 1, 1, '')`;
106 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
107​
108 const error = yield* readChromiumCookies({
109 cookieDatabasePath: filename,
110 platform: "linux",
111 linuxSecretApplication: "chromium",
112 keychainService: undefined,
113 keychainAccount: undefined,
114 }).pipe(Effect.provideService(LinuxBrowserSecretPath, undefined), Effect.flip);
115 expect(error.reason).toBe("keychainUnavailable");
116​
117 yield* Effect.gen(function* () {
118 const sql = yield* SqlClient.SqlClient;
119 yield* sql`insert into cookies values ('v10.example', 'readable', '', ${encryptV10("kept", key)}, '/', 0, 1, 1, 1, '')`;
120 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
121 const keys = {
122 cbcV10: key,
123 cbcV11Error: new ChromiumKeyError({ reason: "keychainUnavailable" }),
124 };
125 const partial = yield* readChromiumCookieDatabase(filename, keys, "linux");
126 expect(partial.cookies.map((cookie) => cookie.value)).toEqual(["kept"]);
127 expect(partial.undecryptable).toBe(1);
128​
129 yield* Effect.gen(function* () {
130 const sql = yield* SqlClient.SqlClient;
131 yield* sql`delete from cookies where name = 'readable'`;
132 yield* sql`update cookies set top_frame_site_key = 'https://top.example'`;
133 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
134 const partitioned = yield* readChromiumCookieDatabase(filename, keys, "linux");
135 expect(partitioned.cookies).toEqual([]);
136 expect(partitioned.undecryptable).toBe(1);
137 }).pipe(Effect.provide(NodeServices.layer)),
138 );
139​
140 it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () =>
141 Effect.gen(function* () {
142 const fileSystem = yield* FileSystem.FileSystem;
143 const directory = yield* fileSystem.makeTempDirectoryScoped({
144 prefix: "t3code-chromium-cookies-",
145 });
146 const filename = `${directory}/Cookies`;
147 const key = Buffer.from("0123456789abcdef");
148​
149 yield* Effect.gen(function* () {
150 const sql = yield* SqlClient.SqlClient;
151 yield* sql`create table meta (key text primary key, value text not null)`;
152 yield* sql`insert into meta values ('version', 23)`;
153 yield* sql`
154 create table cookies (
155 host_key text not null,
156 name text not null,
157 value text not null,
158 encrypted_value blob not null,
159 path text not null,
160 expires_utc integer not null,
161 is_secure integer not null,
162 is_httponly integer not null,
163 samesite integer not null,
164 top_frame_site_key text not null default ''
165 )
166 `;
167 yield* sql`
168 insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
169 ('plain.example', 'plain', 'stored plaintext', ${new Uint8Array()}, '/', 0, 0, 0, -1)
170 `;
171 yield* sql`
172 insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
173 ('secure.example', 'encrypted', '', ${encryptV10("stored encrypted", key)}, '/', 0, 1, 1, 2)
174 `;
175 yield* sql`
176 insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
177 ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0)
178 `;
179 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
180​
181 const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
182​
183 expect(result.undecryptable).toBe(0);
184 expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
185 { name: "plain", value: "stored plaintext" },
186 { name: "encrypted", value: "stored encrypted" },
187 { name: "empty", value: "" },
188 ]);
189 }).pipe(Effect.provide(NodeServices.layer)),
190 );
191​
192 it.effect("enforces domain binding only for schema 24 and newer", () =>
193 Effect.gen(function* () {
194 const fileSystem = yield* FileSystem.FileSystem;
195 const directory = yield* fileSystem.makeTempDirectoryScoped({
196 prefix: "t3code-chromium-cookies-",
197 });
198 const filename = `${directory}/Cookies`;
199 const key = Buffer.from("0123456789abcdef");
200 const boundValue = (host: string, value: string) =>
201 Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]);
202​
203 yield* Effect.gen(function* () {
204 const sql = yield* SqlClient.SqlClient;
205 yield* sql`create table meta (key text primary key, value text not null)`;
206 yield* sql`insert into meta values ('version', 24)`;
207 yield* sql`
208 create table cookies (
209 host_key text not null, name text not null, value text not null,
210 encrypted_value blob not null, path text not null, expires_utc integer not null,
211 is_secure integer not null, is_httponly integer not null, samesite integer not null,
212 top_frame_site_key text not null default ''
213 )
214 `;
215 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
216 ('bound.example', 'valid', '', ${encryptV10(boundValue("bound.example", "kept"), key)}, '/', 0, 1, 0, 0)`;
217 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
218 ('wrong.example', 'mismatch', '', ${encryptV10(boundValue("another.example", "drop"), key)}, '/', 0, 1, 0, 0)`;
219 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
220 ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`;
221 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
222​
223 const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
224​
225 expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
226 { name: "valid", value: "kept" },
227 ]);
228 expect(result.undecryptable).toBe(2);
229 expect(result.undecryptableHosts).toEqual(["wrong.example", "short.example"]);
230 }).pipe(Effect.provide(NodeServices.layer)),
231 );
232​
233 it.effect("decrypts mixed v10 and v11 cookies with their respective keys", () =>
234 Effect.gen(function* () {
235 const fileSystem = yield* FileSystem.FileSystem;
236 const directory = yield* fileSystem.makeTempDirectoryScoped({
237 prefix: "t3code-chromium-cookies-",
238 });
239 const filename = `${directory}/Cookies`;
240 const cbcV10 = Buffer.from("0123456789abcdef");
241 const cbcV11 = Buffer.from("fedcba9876543210");
242 const boundValue = (host: string, value: string) =>
243 Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]);
244​
245 yield* Effect.gen(function* () {
246 const sql = yield* SqlClient.SqlClient;
247 yield* sql`create table meta (key text primary key, value text not null)`;
248 yield* sql`insert into meta values ('version', '24')`;
249 yield* sql`
250 create table cookies (
251 host_key text not null, name text not null, value text not null,
252 encrypted_value blob not null, path text not null, expires_utc integer not null,
253 is_secure integer not null, is_httponly integer not null, samesite integer not null,
254 top_frame_site_key text not null default ''
255 )
256 `;
257 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
258 ('v10.example', 'v10-cookie', '', ${encryptChromium("v10", boundValue("v10.example", "v10 value"), cbcV10)}, '/', 0, 1, 0, 0)`;
259 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
260 ('v11.example', 'v11-cookie', '', ${encryptChromium("v11", boundValue("v11.example", "v11 value"), cbcV11)}, '/', 0, 1, 0, 0)`;
261 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
262​
263 const complete = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcV11 }, "linux");
264 expect(complete.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
265 { name: "v10-cookie", value: "v10 value" },
266 { name: "v11-cookie", value: "v11 value" },
267 ]);
268 expect(complete.undecryptable).toBe(0);
269​
270 const v10Only = yield* readChromiumCookieDatabase(filename, { cbcV10 }, "linux");
271 expect(v10Only.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
272 { name: "v10-cookie", value: "v10 value" },
273 ]);
274 expect(v10Only.undecryptable).toBe(1);
275 expect(v10Only.undecryptableHosts).toEqual(["v11.example"]);
276 }).pipe(Effect.provide(NodeServices.layer)),
277 );
278​
279 it.effect("recovers records written with the empty-passphrase key", () =>
280 Effect.gen(function* () {
281 const fileSystem = yield* FileSystem.FileSystem;
282 const directory = yield* fileSystem.makeTempDirectoryScoped({
283 prefix: "t3code-chromium-cookies-",
284 });
285 const filename = `${directory}/Cookies`;
286 const cbcV10 = Buffer.from("0123456789abcdef");
287 const cbcV11 = Buffer.from("fedcba9876543210");
288 // Some Linux clients encrypted with an empty passphrase (crbug.com/1195256).
289 const cbcEmpty = NodeCrypto.pbkdf2Sync("", "saltysalt", 1, 16, "sha1");
290​
291 yield* Effect.gen(function* () {
292 const sql = yield* SqlClient.SqlClient;
293 yield* sql`create table meta (key text primary key, value text not null)`;
294 yield* sql`insert into meta values ('version', '23')`;
295 yield* sql`
296 create table cookies (
297 host_key text not null, name text not null, value text not null,
298 encrypted_value blob not null, path text not null, expires_utc integer not null,
299 is_secure integer not null, is_httponly integer not null, samesite integer not null,
300 top_frame_site_key text not null default ''
301 )
302 `;
303 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
304 ('ev10.example', 'empty-v10', '', ${encryptChromium("v10", "empty v10 value", cbcEmpty)}, '/', 0, 1, 0, 0)`;
305 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
306 ('ev11.example', 'empty-v11', '', ${encryptChromium("v11", "empty v11 value", cbcEmpty)}, '/', 0, 1, 0, 0)`;
307 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
308​
309 const recovered = yield* readChromiumCookieDatabase(
310 filename,
311 { cbcV10, cbcV11, cbcEmpty },
312 "linux",
313 );
314 expect(recovered.cookies.map(({ name, value }) => ({ name, value }))).toEqual([
315 { name: "empty-v10", value: "empty v10 value" },
316 { name: "empty-v11", value: "empty v11 value" },
317 ]);
318 expect(recovered.undecryptable).toBe(0);
319​
320 const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux");
321 expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]);
322 expect(noV11.undecryptableHosts).toEqual(["ev11.example"]);
323 }).pipe(Effect.provide(NodeServices.layer)),
324 );
325​
326 it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () =>
327 Effect.gen(function* () {
328 const fileSystem = yield* FileSystem.FileSystem;
329 const directory = yield* fileSystem.makeTempDirectoryScoped({
330 prefix: "t3code-chromium-cookies-",
331 });
332 const filename = `${directory}/Cookies`;
333 const key = Buffer.from("0123456789abcdef");
334​
335 yield* Effect.gen(function* () {
336 const sql = yield* SqlClient.SqlClient;
337 yield* sql`create table meta (key text primary key, value integer not null)`;
338 yield* sql`insert into meta values ('version', 23)`;
339 yield* sql`
340 create table cookies (
341 host_key text not null, name text not null, value text not null,
342 encrypted_value blob not null, path text not null, expires_utc integer not null,
343 is_secure integer not null, is_httponly integer not null, samesite integer not null,
344 top_frame_site_key text not null default ''
345 )
346 `;
347 yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values
348 ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`;
349 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
350​
351 const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin");
352 const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux");
353​
354 expect(mac.cookies[0]?.value).toBe("legacy cleartext");
355 expect(mac.undecryptable).toBe(0);
356 expect(linux.cookies[0]?.value).toBe("legacy cleartext");
357 expect(linux.undecryptable).toBe(0);
358 }).pipe(Effect.provide(NodeServices.layer)),
359 );
360​
361 it.effect("reads a cookie database the browser has emptied as having no cookies", () =>
362 Effect.gen(function* () {
363 const fileSystem = yield* FileSystem.FileSystem;
364 const directory = yield* fileSystem.makeTempDirectoryScoped({
365 prefix: "t3code-chromium-cookies-",
366 });
367 const filename = `${directory}/Cookies`;
368 yield* Effect.gen(function* () {
369 const sql = yield* SqlClient.SqlClient;
370 yield* sql`create table scratch (id integer)`;
371 yield* sql`drop table scratch`;
372 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename })));
373​
374 const result = yield* readChromiumCookieDatabase(
375 filename,
376 { cbcV10: Buffer.from("0123456789abcdef") },
377 "darwin",
378 );
379​
380 expect(result).toEqual({ cookies: [], undecryptable: 0, undecryptableHosts: [] });
381 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
382 );
383​
384 it.effect("skips partitioned cookies without breaking pre-CHIPS schemas", () =>
385 Effect.gen(function* () {
386 const fileSystem = yield* FileSystem.FileSystem;
387 const directory = yield* fileSystem.makeTempDirectoryScoped({
388 prefix: "t3code-chromium-cookies-",
389 });
390 const legacyFilename = `${directory}/LegacyCookies`;
391 const chipsFilename = `${directory}/ChipsCookies`;
392 const key = Buffer.from("0123456789abcdef");
393​
394 yield* Effect.gen(function* () {
395 const sql = yield* SqlClient.SqlClient;
396 yield* sql`create table meta (key text primary key, value integer not null)`;
397 yield* sql`insert into meta values ('version', 14)`;
398 yield* sql`
399 create table cookies (
400 host_key text not null, name text not null, value text not null,
401 encrypted_value blob not null, path text not null, expires_utc integer not null,
402 is_secure integer not null, is_httponly integer not null, samesite integer not null
403 )
404 `;
405 yield* sql`insert into cookies values
406 ('legacy.example', 'legacy', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0)`;
407 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: legacyFilename })));
408​
409 yield* Effect.gen(function* () {
410 const sql = yield* SqlClient.SqlClient;
411 yield* sql`create table meta (key text primary key, value integer not null)`;
412 yield* sql`insert into meta values ('version', 23)`;
413 yield* sql`
414 create table cookies (
415 host_key text not null, name text not null, value text not null,
416 encrypted_value blob not null, path text not null, expires_utc integer not null,
417 is_secure integer not null, is_httponly integer not null, samesite integer not null,
418 top_frame_site_key text not null
419 )
420 `;
421 yield* sql`insert into cookies values
422 ('plain.example', 'plain', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0, '')`;
423 yield* sql`insert into cookies values
424 ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`;
425 }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename })));
426​
427 const legacy = yield* readChromiumCookieDatabase(legacyFilename, { cbcV10: key }, "darwin");
428 const chips = yield* readChromiumCookieDatabase(chipsFilename, { cbcV10: key }, "darwin");
429​
430 expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]);
431 expect(legacy.undecryptable).toBe(0);
432 expect(chips.cookies.map(({ name }) => name)).toEqual(["plain"]);
433 expect(chips.undecryptable).toBe(1);
434 expect(chips.undecryptableHosts).toEqual(["partitioned.example"]);
435 }).pipe(Effect.provide(NodeServices.layer)),
436 );
437});