370 lines · 13.0 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the// OSCrypt primitives Chromium uses; Effect has no equivalent./** * Chromium cookie extraction. * * Reads a Chromium-family browser's cookie database and decrypts each record * with the key its prefix calls for. Key acquisition — and the consent it * needs — lives in `ChromiumKeys`. * * Records whose scheme we hold no key for are skipped rather than failing the * whole import: a Linux database can mix `v10` and `v11`. A partial result * reported honestly is more useful than an all-or-nothing error. * * @module ChromiumCookies */import * as NodeCrypto from "node:crypto"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";import * as Effect from "effect/Effect";import * as FileSystem from "effect/FileSystem";import * as Path from "effect/Path";import * as Schema from "effect/Schema";import * as Scope from "effect/Scope";import * as SqlClient from "effect/unstable/sql/SqlClient";import { ChildProcessSpawner } from "effect/unstable/process"; import {  ChromiumKeyError,  ChromiumKeyFailure,  readWindowsKey,  resolveChromiumKeys,  type ChromiumKeyMaterial,} from "./ChromiumKeys.ts";import {  bareHost,  cookieScope,  snapshotCookieDatabase,  type CookieReadResult,  type ImportedCookie,} from "./CookieDatabase.ts"; /** OSCrypt's CBC mode uses a fixed IV of 16 spaces rather than a per-record one. */const AES_CBC_IV = Buffer.alloc(16, 0x20);const AES_GCM_NONCE_LENGTH = 12;const AES_GCM_TAG_LENGTH = 16;const isChromiumKeyError = Schema.is(ChromiumKeyError); /** * Every way the read can fail: the key failures, plus the ones this module * raises itself. */export const ChromiumCookieReadReason = Schema.Literals([  // `readFailed` already comes from the key failures, so it is not repeated.  ...ChromiumKeyFailure.literals,  "browserRunning",]);export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type; export class ChromiumCookieReadError extends Schema.TaggedError<ChromiumCookieReadError>()(  "ChromiumCookieReadError",  {    reason: ChromiumCookieReadReason,    /**     * Which database the read was for. Without it every `readFailed` and     * keychain failure logs identically, and a user with several browsers     * installed has no way to tell which one refused.     */    cookieDatabasePath: Schema.String,    /** Kept for the log; never surfaced to the user. */    cause: Schema.optional(Schema.Defect()),  },) {  override get message(): string {    return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`;  }} /** Row shape of the cookie table, decoded rather than cast. */const CookieRow = Schema.Struct({  host_key: Schema.String,  name: Schema.String,  value: Schema.String,  encrypted_value: Schema.Uint8Array,  path: Schema.String,  expires_seconds: Schema.Number,  is_secure: Schema.Number,  is_httponly: Schema.Number,  samesite: Schema.Number,  top_frame_site_key: Schema.String,});const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));const SchemaVersion = Schema.Union([  NonNegativeInt,  Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),]);const decodeSchemaVersion = Schema.decodeUnknownEffect(  Schema.Tuple([Schema.Struct({ value: SchemaVersion })]),); /** * Chromium stores `SameSite` as an int: -1 = unspecified, 0 = none, 1 = lax, * 2 = strict. Unspecified is imported as Electron's own `unspecified` rather * than pinned to Lax, so the target browser applies its default just as the * source did; anything unrecognised lands there too, since guessing "none" * would widen a cookie's scope on import. */const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => {  if (value === 0) return "no_restriction";  if (value === 1) return "lax";  if (value === 2) return "strict";  return "unspecified";}; /** * Chromium timestamps count microseconds from 1601-01-01; Electron wants * seconds from the UNIX epoch. The microsecond value overflows JavaScript's * safe integer range and `node:sqlite` refuses to narrow it, so the division * happens in SQL and this only ever sees seconds. */const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;const toUnixSeconds = (webkitSeconds: number): number | undefined => {  if (webkitSeconds <= 0) return undefined;  return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS;}; /** * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding * a cookie to its domain. Strip it when present. */const stripDomainBinding = (  plaintext: Buffer,  domain: string,  schemaVersion: number,): Buffer | null => {  if (schemaVersion < 24) return plaintext;  const domainHash = NodeCrypto.createHash("sha256").update(domain).digest();  return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash)    ? plaintext.subarray(32)    : null;}; const decryptCbc = (  payload: Buffer,  key: Buffer,  domain: string,  schemaVersion: number,): string | null => {  try {    const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV);    decipher.setAutoPadding(true);    const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]);    return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;  } catch {    return null;  }}; const decryptGcm = (  payload: Buffer,  key: Buffer,  domain: string,  schemaVersion: number,): string | null => {  if (payload.length < AES_GCM_NONCE_LENGTH + AES_GCM_TAG_LENGTH) return null;  try {    const nonce = payload.subarray(0, AES_GCM_NONCE_LENGTH);    const ciphertext = payload.subarray(AES_GCM_NONCE_LENGTH, -AES_GCM_TAG_LENGTH);    const tag = payload.subarray(-AES_GCM_TAG_LENGTH);    const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce);    decipher.setAuthTag(tag);    const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);    return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null;  } catch {    return null;  }}; /** * Decrypts one stored value, choosing the scheme from its prefix. Returns null * when no key covers that scheme — including Windows' app-bound `v20`, which * this build has no key for at all. */export function decryptChromiumValue(  encrypted: Uint8Array,  keys: ChromiumKeyMaterial,  domain: string,  schemaVersion = 23,  platform: NodeJS.Platform = "linux",): string | null {  const buffer = Buffer.from(encrypted);  if (buffer.length === 0) return "";  const prefix = buffer.subarray(0, 3).toString("latin1");  const payload = buffer.subarray(3);   // Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and  // intentionally have no key here, so they fall through as undecryptable.  if (platform === "win32") {    return prefix === "v10" && keys.gcmV10      ? decryptGcm(payload, keys.gcmV10, domain, schemaVersion)      : null;  }   // Chromium retries a failed record with a key derived from an empty  // passphrase, because some Linux clients wrote data that way  // (crbug.com/1195256). A record whose own key is missing entirely stays  // skipped, matching Chromium.  if (prefix === "v10") {    if (!keys.cbcV10) return null;    return (      decryptCbc(payload, keys.cbcV10, domain, schemaVersion) ??      (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null)    );  }  if (prefix === "v11") {    if (!keys.cbcV11) return null;    return (      decryptCbc(payload, keys.cbcV11, domain, schemaVersion) ??      (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null)    );  }  // No recognised prefix: Chromium on macOS and Linux both treat this as  // legacy data stored in the clear and return it as-is, so it is a readable  // cookie rather than an undecryptable one. Windows is the exception — its  // app-bound `v20` blobs also lack these prefixes and must not be read as  // plaintext — but Windows Chromium is not importable here at all.  if (platform === "darwin" || platform === "linux") {    return stripDomainBinding(buffer, domain, schemaVersion)?.toString("utf8") ?? null;  }  return null;} /** Reads and decodes one snapshotted Chromium cookie database. */export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")(  function* (snapshotPath: string, keys: ChromiumKeyMaterial, platform: NodeJS.Platform) {    const result = yield* Effect.gen(function* () {      const sql = yield* SqlClient.SqlClient;      const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe(        Effect.flatMap(decodeSchemaVersion),        Effect.map(([row]) => row.value),      );      const raw =        schemaVersion >= 15          ? yield* sql`select host_key, name, value, encrypted_value, path,                expires_utc / 1000000 as expires_seconds, is_secure, is_httponly,                samesite, top_frame_site_key from cookies`          : yield* sql`select host_key, name, value, encrypted_value, path,                expires_utc / 1000000 as expires_seconds, is_secure, is_httponly,                samesite, '' as top_frame_site_key from cookies`;      return { rows: yield* decodeCookieRows(raw), schemaVersion };    }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true })));     const cookies: ImportedCookie[] = [];    let undecryptable = 0;    const undecryptableHosts = new Set<string>();    for (const row of result.rows) {      if (row.top_frame_site_key !== "") {        undecryptable += 1;        undecryptableHosts.add(bareHost(row.host_key));        continue;      }      const value =        row.encrypted_value.length === 0          ? row.value          : decryptChromiumValue(              row.encrypted_value,              keys,              row.host_key,              result.schemaVersion,              platform,            );      if (value === null) {        undecryptable += 1;        undecryptableHosts.add(bareHost(row.host_key));        continue;      }      const secure = row.is_secure === 1;      const scope = cookieScope(row.host_key, row.path, secure);      cookies.push({        url: scope.url,        name: row.name,        value,        domain: scope.domain,        path: row.path,        secure,        httpOnly: row.is_httponly === 1,        expirationDate: toUnixSeconds(row.expires_seconds),        sameSite: sameSiteFromColumn(row.samesite),      });    }    // Keep partial imports, but do not call a missing key a successful import    // when it prevented every otherwise importable cookie from being read.    if (      cookies.length === 0 &&      keys.cbcV11Error !== undefined &&      result.rows.some(        (row) =>          row.top_frame_site_key === "" &&          Buffer.from(row.encrypted_value.subarray(0, 3)).toString("latin1") === "v11",      )    ) {      return yield* keys.cbcV11Error;    }    return {      cookies,      undecryptable,      undecryptableHosts: [...undecryptableHosts],    } satisfies CookieReadResult;  },); export interface ChromiumCookieSource {  readonly cookieDatabasePath: string;  readonly keychainService: string | undefined;  readonly keychainAccount: string | undefined;  readonly linuxSecretApplication: string | undefined;  readonly windowsLocalStatePath?: string;  /** Supplied by the caller from `HostProcessPlatform` rather than read here. */  readonly platform: NodeJS.Platform;} export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* (  source: ChromiumCookieSource,): Effect.fn.Return<  CookieReadResult,  ChromiumCookieReadError,  FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner> {  const keys = yield* (    source.platform === "win32" && source.windowsLocalStatePath      ? readWindowsKey(source.windowsLocalStatePath).pipe(Effect.map((gcmV10) => ({ gcmV10 })))      : resolveChromiumKeys({          platform: source.platform,          keychainService: source.keychainService,          keychainAccount: source.keychainAccount,          linuxSecretApplication: source.linuxSecretApplication,        })  ).pipe(    Effect.mapError(      (cause: ChromiumKeyError) =>        new ChromiumCookieReadError({          reason: cause.reason,          cookieDatabasePath: source.cookieDatabasePath,          cause,        }),    ),  );   const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe(    Effect.mapError(      (cause) =>        new ChromiumCookieReadError({          reason: "readFailed",          cookieDatabasePath: source.cookieDatabasePath,          cause,        }),    ),  );   return yield* readChromiumCookieDatabase(snapshotPath, keys, source.platform).pipe(    Effect.mapError(      (cause) =>        new ChromiumCookieReadError({          reason: isChromiumKeyError(cause) ? cause.reason : "readFailed",          cookieDatabasePath: source.cookieDatabasePath,          cause,        }),    ),  );});