// @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", { 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" }); });