// @effect-diagnostics nodeBuiltinImport:off - Encrypts fixtures with the same // OSCrypt primitives the module under test decrypts. import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { decryptChromiumValue, readChromiumCookieDatabase, readChromiumCookies, } from "./ChromiumCookies.ts"; import { ChromiumKeyError } from "./ChromiumKeys.ts"; import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; import { cookieScope } from "./CookieDatabase.ts"; const encryptChromium = ( prefix: "v10" | "v11", value: string | Buffer, key: Buffer, ): Uint8Array => { const cipher = NodeCrypto.createCipheriv("aes-128-cbc", key, Buffer.alloc(16, 0x20)); return Buffer.concat([Buffer.from(prefix), cipher.update(value), cipher.final()]); }; const encryptV10 = (value: string | Buffer, key: Buffer): Uint8Array => encryptChromium("v10", value, key); const encryptWindowsV10 = (value: string | Buffer, key: Buffer): Uint8Array => { const nonce = Buffer.from("0123456789ab"); const cipher = NodeCrypto.createCipheriv("aes-256-gcm", key, nonce); const encrypted = Buffer.concat([cipher.update(value), cipher.final()]); return Buffer.concat([Buffer.from("v10"), nonce, encrypted, cipher.getAuthTag()]); }; describe("cookieScope", () => { it("keeps a host-only cookie host-only", () => { // Chromium stores a host-only cookie without a leading dot. Passing any // `domain` to Electron makes it a domain cookie and re-adds the dot, which // would expose the cookie to every subdomain it was never scoped to. expect(cookieScope("example.test", "/", true)).toEqual({ url: "https://example.test/", domain: undefined, }); }); it("preserves a domain cookie's leading dot", () => { expect(cookieScope(".example.test", "/app", true)).toEqual({ url: "https://example.test/app", domain: ".example.test", }); }); it("matches the scheme to the secure flag", () => { expect(cookieScope("example.test", "/", false).url).toBe("http://example.test/"); }); it("brackets bare IPv6 hosts without duplicating existing brackets", () => { expect(cookieScope("::1", "/", false)).toEqual({ url: "http://[::1]/", domain: undefined, }); expect(cookieScope("[::1]", "/app", true)).toEqual({ url: "https://[::1]/app", domain: undefined, }); }); }); describe("readChromiumCookieDatabase", () => { it("decrypts Windows v10 AES-GCM records and rejects app-bound v20 records", () => { const key = Buffer.from("0123456789abcdef0123456789abcdef"); const host = ".example.test"; const bound = Buffer.concat([ NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from("windows value"), ]); expect( decryptChromiumValue(encryptWindowsV10(bound, key), { gcmV10: key }, host, 24, "win32"), ).toBe("windows value"); expect( decryptChromiumValue(Buffer.from("v20app-bound"), { gcmV10: key }, host, 24, "win32"), ).toBeNull(); expect( decryptChromiumValue( encryptWindowsV10(bound, Buffer.alloc(32, 1)), { gcmV10: key }, host, 24, "win32", ), ).toBeNull(); }); it.effect( "reports the missing key when no cookies can be read, while preserving partial imports", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-missing-key-", }); const filename = `${directory}/Cookies`; const key = Buffer.from("0123456789abcdef"); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`create table meta (key text primary key, value text not null)`; yield* sql`insert into meta values ('version', 23)`; yield* sql`create table cookies ( host_key text not null, name text not null, value text not null, encrypted_value blob not null, path text not null, expires_utc integer not null, is_secure integer not null, is_httponly integer not null, samesite integer not null, top_frame_site_key text not null default '' )`; yield* sql`insert into cookies values ('v11.example', 'session', '', ${encryptChromium("v11", "secret", key)}, '/', 0, 1, 1, 1, '')`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); const error = yield* readChromiumCookies({ cookieDatabasePath: filename, platform: "linux", linuxSecretApplication: "chromium", keychainService: undefined, keychainAccount: undefined, }).pipe(Effect.provideService(LinuxBrowserSecretPath, undefined), Effect.flip); expect(error.reason).toBe("keychainUnavailable"); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`insert into cookies values ('v10.example', 'readable', '', ${encryptV10("kept", key)}, '/', 0, 1, 1, 1, '')`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); const keys = { cbcV10: key, cbcV11Error: new ChromiumKeyError({ reason: "keychainUnavailable" }), }; const partial = yield* readChromiumCookieDatabase(filename, keys, "linux"); expect(partial.cookies.map((cookie) => cookie.value)).toEqual(["kept"]); expect(partial.undecryptable).toBe(1); // A partitioned-only jar does not need its key: it is skipped separately. yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`delete from cookies where name = 'readable'`; yield* sql`update cookies set top_frame_site_key = 'https://top.example'`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); const partitioned = yield* readChromiumCookieDatabase(filename, keys, "linux"); expect(partitioned.cookies).toEqual([]); expect(partitioned.undecryptable).toBe(1); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("reads plaintext, encrypted, and genuinely empty cookie values", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-chromium-cookies-", }); const filename = `${directory}/Cookies`; const key = Buffer.from("0123456789abcdef"); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`create table meta (key text primary key, value text not null)`; yield* sql`insert into meta values ('version', 23)`; yield* sql` create table cookies ( host_key text not null, name text not null, value text not null, encrypted_value blob not null, path text not null, expires_utc integer not null, is_secure integer not null, is_httponly integer not null, samesite integer not null, top_frame_site_key text not null default '' ) `; yield* sql` insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('plain.example', 'plain', 'stored plaintext', ${new Uint8Array()}, '/', 0, 0, 0, -1) `; yield* sql` insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('secure.example', 'encrypted', '', ${encryptV10("stored encrypted", key)}, '/', 0, 1, 1, 2) `; yield* sql` insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('empty.example', 'empty', '', ${new Uint8Array()}, '/', 0, 0, 0, 0) `; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); expect(result.undecryptable).toBe(0); expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ { name: "plain", value: "stored plaintext" }, { name: "encrypted", value: "stored encrypted" }, { name: "empty", value: "" }, ]); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("enforces domain binding only for schema 24 and newer", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-chromium-cookies-", }); const filename = `${directory}/Cookies`; const key = Buffer.from("0123456789abcdef"); const boundValue = (host: string, value: string) => Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`create table meta (key text primary key, value text not null)`; yield* sql`insert into meta values ('version', 24)`; yield* sql` create table cookies ( host_key text not null, name text not null, value text not null, encrypted_value blob not null, path text not null, expires_utc integer not null, is_secure integer not null, is_httponly integer not null, samesite integer not null, top_frame_site_key text not null default '' ) `; yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('bound.example', 'valid', '', ${encryptV10(boundValue("bound.example", "kept"), key)}, '/', 0, 1, 0, 0)`; yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('wrong.example', 'mismatch', '', ${encryptV10(boundValue("another.example", "drop"), key)}, '/', 0, 1, 0, 0)`; yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('short.example', 'short', '', ${encryptV10("short value", key)}, '/', 0, 1, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); expect(result.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ { name: "valid", value: "kept" }, ]); expect(result.undecryptable).toBe(2); expect(result.undecryptableHosts).toEqual(["wrong.example", "short.example"]); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("decrypts mixed v10 and v11 cookies with their respective keys", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-chromium-cookies-", }); const filename = `${directory}/Cookies`; const cbcV10 = Buffer.from("0123456789abcdef"); const cbcV11 = Buffer.from("fedcba9876543210"); const boundValue = (host: string, value: string) => Buffer.concat([NodeCrypto.createHash("sha256").update(host).digest(), Buffer.from(value)]); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`create table meta (key text primary key, value text not null)`; yield* sql`insert into meta values ('version', '24')`; yield* sql` create table cookies ( host_key text not null, name text not null, value text not null, encrypted_value blob not null, path text not null, expires_utc integer not null, is_secure integer not null, is_httponly integer not null, samesite integer not null, top_frame_site_key text not null default '' ) `; yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('v10.example', 'v10-cookie', '', ${encryptChromium("v10", boundValue("v10.example", "v10 value"), cbcV10)}, '/', 0, 1, 0, 0)`; yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('v11.example', 'v11-cookie', '', ${encryptChromium("v11", boundValue("v11.example", "v11 value"), cbcV11)}, '/', 0, 1, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); const complete = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcV11 }, "linux"); expect(complete.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ { name: "v10-cookie", value: "v10 value" }, { name: "v11-cookie", value: "v11 value" }, ]); expect(complete.undecryptable).toBe(0); const v10Only = yield* readChromiumCookieDatabase(filename, { cbcV10 }, "linux"); expect(v10Only.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ { name: "v10-cookie", value: "v10 value" }, ]); expect(v10Only.undecryptable).toBe(1); expect(v10Only.undecryptableHosts).toEqual(["v11.example"]); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("recovers records written with the empty-passphrase key", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-chromium-cookies-", }); const filename = `${directory}/Cookies`; const cbcV10 = Buffer.from("0123456789abcdef"); const cbcV11 = Buffer.from("fedcba9876543210"); // The key some Linux clients actually encrypted with (crbug.com/1195256): // OSCrypt's derivation over an empty passphrase. const cbcEmpty = NodeCrypto.pbkdf2Sync("", "saltysalt", 1, 16, "sha1"); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`create table meta (key text primary key, value text not null)`; yield* sql`insert into meta values ('version', '23')`; yield* sql` create table cookies ( host_key text not null, name text not null, value text not null, encrypted_value blob not null, path text not null, expires_utc integer not null, is_secure integer not null, is_httponly integer not null, samesite integer not null, top_frame_site_key text not null default '' ) `; yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('ev10.example', 'empty-v10', '', ${encryptChromium("v10", "empty v10 value", cbcEmpty)}, '/', 0, 1, 0, 0)`; yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('ev11.example', 'empty-v11', '', ${encryptChromium("v11", "empty v11 value", cbcEmpty)}, '/', 0, 1, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); // The records' own keys fail, and the empty key recovers both — the // retry Chromium itself performs. const recovered = yield* readChromiumCookieDatabase( filename, { cbcV10, cbcV11, cbcEmpty }, "linux", ); expect(recovered.cookies.map(({ name, value }) => ({ name, value }))).toEqual([ { name: "empty-v10", value: "empty v10 value" }, { name: "empty-v11", value: "empty v11 value" }, ]); expect(recovered.undecryptable).toBe(0); // Matching Chromium: a record whose own key is missing entirely is not // retried with the empty key. const noV11 = yield* readChromiumCookieDatabase(filename, { cbcV10, cbcEmpty }, "linux"); expect(noV11.cookies.map(({ name }) => name)).toEqual(["empty-v10"]); expect(noV11.undecryptableHosts).toEqual(["ev11.example"]); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("preserves arbitrary long encrypted values from pre-24 schemas", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-chromium-cookies-", }); const filename = `${directory}/Cookies`; const key = Buffer.from("0123456789abcdef"); const value = "x".repeat(32) + " legacy value"; yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`create table meta (key text primary key, value text not null)`; yield* sql`insert into meta values ('version', 23)`; yield* sql` create table cookies ( host_key text not null, name text not null, value text not null, encrypted_value blob not null, path text not null, expires_utc integer not null, is_secure integer not null, is_httponly integer not null, samesite integer not null, top_frame_site_key text not null default '' ) `; yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('legacy.example', 'legacy', '', ${encryptV10(value, key)}, '/', 0, 0, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); const result = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); expect(result.cookies[0]?.value).toBe(value); expect(result.undecryptable).toBe(0); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("rejects a malformed text schema version", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-chromium-cookies-", }); const filename = `${directory}/Cookies`; yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`create table meta (key text primary key, value text not null)`; yield* sql`insert into meta values ('version', 'not-a-version')`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); const error = yield* readChromiumCookieDatabase( filename, { cbcV10: Buffer.from("0123456789abcdef") }, "darwin", ).pipe(Effect.flip); expect(error._tag).toBe("SchemaError"); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("treats unversioned encrypted values as legacy plaintext on macOS and Linux", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-chromium-cookies-", }); const filename = `${directory}/Cookies`; const key = Buffer.from("0123456789abcdef"); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`create table meta (key text primary key, value integer not null)`; yield* sql`insert into meta values ('version', 23)`; yield* sql` create table cookies ( host_key text not null, name text not null, value text not null, encrypted_value blob not null, path text not null, expires_utc integer not null, is_secure integer not null, is_httponly integer not null, samesite integer not null, top_frame_site_key text not null default '' ) `; yield* sql`insert into cookies (host_key, name, value, encrypted_value, path, expires_utc, is_secure, is_httponly, samesite) values ('legacy.example', 'legacy', '', ${Buffer.from("legacy cleartext")}, '/', 0, 0, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename }))); // Chromium's OSCrypt returns unprefixed data as-is on both platforms // (os_crypt_mac.mm and os_crypt_linux.cc: "old data saved as clear // text"), so neither counts it as undecryptable. const mac = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "darwin"); const linux = yield* readChromiumCookieDatabase(filename, { cbcV10: key }, "linux"); expect(mac.cookies[0]?.value).toBe("legacy cleartext"); expect(mac.undecryptable).toBe(0); expect(linux.cookies[0]?.value).toBe("legacy cleartext"); expect(linux.undecryptable).toBe(0); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); it.effect("skips partitioned cookies without breaking pre-CHIPS schemas", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3code-chromium-cookies-", }); const legacyFilename = `${directory}/LegacyCookies`; const chipsFilename = `${directory}/ChipsCookies`; const key = Buffer.from("0123456789abcdef"); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`create table meta (key text primary key, value integer not null)`; yield* sql`insert into meta values ('version', 14)`; yield* sql` create table cookies ( host_key text not null, name text not null, value text not null, encrypted_value blob not null, path text not null, expires_utc integer not null, is_secure integer not null, is_httponly integer not null, samesite integer not null ) `; yield* sql`insert into cookies values ('legacy.example', 'legacy', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0)`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: legacyFilename }))); yield* Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* sql`create table meta (key text primary key, value integer not null)`; yield* sql`insert into meta values ('version', 23)`; yield* sql` create table cookies ( host_key text not null, name text not null, value text not null, encrypted_value blob not null, path text not null, expires_utc integer not null, is_secure integer not null, is_httponly integer not null, samesite integer not null, top_frame_site_key text not null ) `; yield* sql`insert into cookies values ('plain.example', 'plain', 'kept', ${new Uint8Array()}, '/', 0, 0, 0, 0, '')`; yield* sql`insert into cookies values ('partitioned.example', 'partitioned', 'must skip', ${new Uint8Array()}, '/', 0, 1, 0, 0, 'https://top.example')`; }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: chipsFilename }))); const legacy = yield* readChromiumCookieDatabase(legacyFilename, { cbcV10: key }, "darwin"); const chips = yield* readChromiumCookieDatabase(chipsFilename, { cbcV10: key }, "darwin"); expect(legacy.cookies.map(({ name }) => name)).toEqual(["legacy"]); expect(legacy.undecryptable).toBe(0); expect(chips.cookies.map(({ name }) => name)).toEqual(["plain"]); expect(chips.undecryptable).toBe(1); expect(chips.undecryptableHosts).toEqual(["partitioned.example"]); }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), ); });