335 lines · 13.0 KB · TypeScript
RawDownload
// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the// OSCrypt key derivation Chromium uses; Effect has no equivalent./** * Chromium cookie-encryption keys, per platform. * * Chromium calls this OSCrypt, and it works differently on each OS: * * - **macOS** keeps one key in the login keychain. Reading it prompts the *   user, which is the consent this feature is built around. * - **Linux** may keep a key in libsecret/kwallet (`v11` records), or use a *   hardcoded `peanuts` passphrase when no keyring is available (`v10`). Both *   can appear in the same database, so both are derived up front and chosen *   per record. * * - **Windows** legacy Chromium stores protect a random AES key with DPAPI. *   App-Bound Encryption remains deliberately unsupported. * * @module ChromiumKeys */import * as NodeCrypto from "node:crypto"; import { HostProcessEnvironment } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect";import * as Encoding from "effect/Encoding";import * as FileSystem from "effect/FileSystem";import * as Schema from "effect/Schema";import * as Stream from "effect/Stream";import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; const KEY_SALT = "saltysalt";const KEY_LENGTH = 16;/** macOS stretches the keychain secret; Linux uses a single iteration. */const MAC_KEY_ITERATIONS = 1003;const LINUX_KEY_ITERATIONS = 1;/** Chromium's documented fallback passphrase when no Linux keyring is present. */const LINUX_FALLBACK_PASSPHRASE = "peanuts"; export const ChromiumKeyFailure = Schema.Literals([  "needsKeychainApproval",  "keychainItemMissing",  "keychainUnavailable",  "unsupportedPlatform",  /** The key store itself could not be read, as opposed to holding no key. */  "readFailed",]);export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type; export class ChromiumKeyError extends Schema.TaggedError<ChromiumKeyError>()("ChromiumKeyError", {  reason: ChromiumKeyFailure,  /** Kept for the log; never surfaced to the user. */  cause: Schema.optional(Schema.Defect()),}) {  override get message(): string {    return `Could not obtain the Chromium cookie key: ${this.reason}.`;  }} /** * Keys to try, indexed by the record prefix they decrypt. A database can hold * records written under more than one scheme, so a missing entry means those * records are skipped rather than the whole import failing. */export interface ChromiumKeyMaterial {  /** AES-128-CBC on macOS, and the keyring-free Linux fallback. */  readonly cbcV10?: Buffer;  /** AES-128-CBC, Linux keyring-derived. */  readonly cbcV11?: Buffer;  /** Retained so an import that needs this key can report why it is missing. */  readonly cbcV11Error?: ChromiumKeyError;  /**   * AES-128-CBC from an empty passphrase. Some Linux clients wrote records   * with it (crbug.com/1195256), so Chromium — and this import — retry with it   * after a record's own key fails.   */  readonly cbcEmpty?: Buffer;  /** AES-256-GCM key used by pre-App-Bound Chromium on Windows. */  readonly gcmV10?: Buffer;} const derive = (passphrase: string, iterations: number) =>  NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); /** * Reads the macOS OSCrypt secret from the login keychain. * * Uses the in-process Keychain API rather than shelling out to * `/usr/bin/security`, because macOS attributes both the consent prompt and the * resulting ACL entry to the binary that asks. Via the CLI the prompt says * "security" and "Always Allow" grants trust to a tool every process on the * machine can invoke; in-process it names this app and the grant belongs to it. * (In an unsigned dev build the name is the dev binary, not the shipped app * identity.) * * Deliberately untimed: macOS answers this with a modal, and a timeout racing * the user means the prompt can be approved while nothing is left listening, * which reads as "approving did nothing". */const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* (  service: string,  account: string,) {  // Only macOS cookie imports need this binding; loading it at startup can  // prevent the desktop from opening on platforms that never use it.  const Keyring = yield* Effect.tryPromise({    try: () => import("@napi-rs/keyring"),    catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),  });  const secret = yield* Effect.try({    try: () => new Keyring.Entry(service, account).getPassword(),    catch: (cause) => {      const message = String((cause as { message?: unknown } | undefined)?.message ?? "");      // Distinguish the causes rather than reporting "approve the prompt" for      // a failure approving cannot fix.      const missing = /no (matching )?entry|not found/i.test(message);      return new ChromiumKeyError({        reason: missing ? "keychainItemMissing" : "needsKeychainApproval",        cause,      });    },  });  if (secret === null || secret === "") {    return yield* new ChromiumKeyError({ reason: "keychainItemMissing" });  }  return secret;}); /** * The bundled helper searches Chromium's libsecret schema and application * attribute, retaining the desktop's normal unlock prompt. Its exit codes * distinguish a missing key, denied access, and an unavailable keyring without * parsing localized error messages. Stdout is the unmodified secret. */export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* (  application: string,) {  return yield* Effect.scoped(    Effect.gen(function* () {      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;      const environment = yield* HostProcessEnvironment;      const helper = yield* LinuxBrowserSecretPath;      if (helper === undefined) {        return yield* new ChromiumKeyError({ reason: "keychainUnavailable" });      }      const handle = yield* spawner        .spawn(ChildProcess.make(helper, [application], { stdin: "ignore", env: environment }))        .pipe(          Effect.mapError(            (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),          ),        );      const [secret, , exitCode] = yield* Effect.all(        [          handle.stdout.pipe(Stream.decodeText(), Stream.mkString),          handle.stderr.pipe(Stream.runDrain),          handle.exitCode,        ],        { concurrency: "unbounded" },      ).pipe(        Effect.mapError((cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause })),      );      if (Number(exitCode) !== 0) {        return yield* new ChromiumKeyError({          reason:            Number(exitCode) === 2              ? "keychainItemMissing"              : Number(exitCode) === 3                ? "needsKeychainApproval"                : "keychainUnavailable",        });      }      if (secret === "") {        return yield* new ChromiumKeyError({ reason: "keychainItemMissing" });      }      return secret;    }),  );}); const WindowsLocalState = Schema.Struct({  os_crypt: Schema.Struct({    encrypted_key: Schema.String,    app_bound_encrypted_key: Schema.optional(Schema.String),  }),});const decodeWindowsLocalState = Schema.decodeUnknownEffect(  Schema.fromJsonString(WindowsLocalState),);const DPAPI_PREFIX = Buffer.from("DPAPI");const WINDOWS_KEY_LENGTH = 32;const WINDOWS_DPAPI_SCRIPT =  "Add-Type -AssemblyName System.Security;" +  "$value=[Console]::In.ReadToEnd();" +  "$encrypted=[Convert]::FromBase64String($value);" +  "$plain=[Security.Cryptography.ProtectedData]::Unprotect($encrypted,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);" +  "[Console]::Out.Write([Convert]::ToBase64String($plain))"; export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrappedKey")(function* (  contents: string,) {  const state = yield* decodeWindowsLocalState(contents).pipe(    Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),  );  if (state.os_crypt.app_bound_encrypted_key !== undefined) {    return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" });  }  const wrapped = yield* Effect.fromResult(    Encoding.decodeBase64(state.os_crypt.encrypted_key),  ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));  const wrappedBuffer = Buffer.from(wrapped);  if (!wrappedBuffer.subarray(0, DPAPI_PREFIX.length).equals(DPAPI_PREFIX)) {    return yield* new ChromiumKeyError({ reason: "readFailed" });  }  return wrappedBuffer.subarray(DPAPI_PREFIX.length);}); /** Unwraps a key with the current Windows user's DPAPI identity. */export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* (  wrapped: Buffer,) {  const environment = yield* HostProcessEnvironment;  return yield* Effect.scoped(    Effect.gen(function* () {      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;      const windowsRoot = environment.SystemRoot ?? environment.WINDIR;      const powershell = windowsRoot        ? `${windowsRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`        : "powershell.exe";      const handle = yield* spawner        .spawn(          ChildProcess.make(            powershell,            [              "-NoLogo",              "-NoProfile",              "-NonInteractive",              "-WindowStyle",              "Hidden",              "-Command",              WINDOWS_DPAPI_SCRIPT,            ],            {              env: environment,              stdin: Stream.encodeText(Stream.make(wrapped.toString("base64"))),            },          ),        )        .pipe(          Effect.mapError(            (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),          ),        );      const [plainEncoded, , exitCode] = yield* Effect.all(        [          handle.stdout.pipe(Stream.decodeText(), Stream.mkString),          handle.stderr.pipe(Stream.runDrain),          handle.exitCode,        ],        { concurrency: "unbounded" },      ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));      if (Number(exitCode) !== 0) {        return yield* new ChromiumKeyError({ reason: "readFailed" });      }      const plain = yield* Effect.fromResult(Encoding.decodeBase64(plainEncoded)).pipe(        Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),      );      if (plain.length !== WINDOWS_KEY_LENGTH) {        return yield* new ChromiumKeyError({ reason: "readFailed" });      }      return Buffer.from(plain);    }),  );}); /** Reads and unwraps a legacy Windows Chromium key without exposing it in argv. */export const readWindowsKey = Effect.fn("ChromiumKeys.readWindowsKey")(function* (  localStatePath: string,) {  const fileSystem = yield* FileSystem.FileSystem;  const contents = yield* fileSystem    .readFileString(localStatePath)    .pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));  return yield* unwrapWindowsDpapiKey(yield* decodeWindowsWrappedKey(contents));}); export interface ChromiumKeyRequest {  readonly platform: NodeJS.Platform;  readonly keychainService: string | undefined;  readonly keychainAccount: string | undefined;  readonly linuxSecretApplication: string | undefined;} export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* (  request: ChromiumKeyRequest,): Effect.fn.Return<  ChromiumKeyMaterial,  ChromiumKeyError,  ChildProcessSpawner.ChildProcessSpawner> {  if (request.platform === "darwin") {    if (!request.keychainService || !request.keychainAccount) {      return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" });    }    const secret = yield* readKeychainSecret(request.keychainService, request.keychainAccount);    return { cbcV10: derive(secret, MAC_KEY_ITERATIONS) };  }   if (request.platform === "linux") {    // The fallback passphrase always applies to `v10` records; a keyring    // secret, when one is reachable, additionally unlocks `v11`. Preserve its    // failure until the reader knows whether any cookies needed that key.    const keyringSecret = request.linuxSecretApplication      ? yield* readLinuxSecret(request.linuxSecretApplication).pipe(          // v10 remains importable when Secret Service is absent or does not          // contain a key. An explicit denial/lock/cancel remains a consent          // failure rather than being silently downgraded.          Effect.catch((error) =>            error.reason === "needsKeychainApproval" ? Effect.fail(error) : Effect.succeed(error),          ),        )      : undefined;    return {      cbcV10: derive(LINUX_FALLBACK_PASSPHRASE, LINUX_KEY_ITERATIONS),      ...(typeof keyringSecret === "string"        ? { cbcV11: derive(keyringSecret, LINUX_KEY_ITERATIONS) }        : keyringSecret          ? { cbcV11Error: keyringSecret }          : {}),      cbcEmpty: derive("", LINUX_KEY_ITERATIONS),    };  }   return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" });});