493 lines · 23.5 KB · TypeScript
RawDownload
// @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),  );});