| 1 | // @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the |
| 2 | // OSCrypt key derivation Chromium uses; Effect has no equivalent. |
| 3 | /** |
| 4 | * Chromium cookie-encryption keys, per platform. |
| 5 | * |
| 6 | * Chromium calls this OSCrypt, and it works differently on each OS: |
| 7 | * |
| 8 | * - **macOS** keeps one key in the login keychain. Reading it prompts the |
| 9 | * user, which is the consent this feature is built around. |
| 10 | * - **Linux** may keep a key in libsecret/kwallet (`v11` records), or use a |
| 11 | * hardcoded `peanuts` passphrase when no keyring is available (`v10`). Both |
| 12 | * can appear in the same database, so both are derived up front and chosen |
| 13 | * per record. |
| 14 | * |
| 15 | * - **Windows** legacy Chromium stores protect a random AES key with DPAPI. |
| 16 | * App-Bound Encryption remains deliberately unsupported. |
| 17 | * |
| 18 | * @module ChromiumKeys |
| 19 | */ |
| 20 | import * as NodeCrypto from "node:crypto"; |
| 21 | |
| 22 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 23 | |
| 24 | import * as Effect from "effect/Effect"; |
| 25 | import * as Base64 from "effect/encoding/Base64"; |
| 26 | import * as FileSystem from "effect/FileSystem"; |
| 27 | import * as Schema from "effect/Schema"; |
| 28 | import * as Stream from "effect/Stream"; |
| 29 | import { ChildProcess, ChildProcessSpawner } from "effect/process"; |
| 30 | import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts"; |
| 31 | |
| 32 | const KEY_SALT = "saltysalt"; |
| 33 | const KEY_LENGTH = 16; |
| 34 | /** macOS stretches the keychain secret; Linux uses a single iteration. */ |
| 35 | const MAC_KEY_ITERATIONS = 1003; |
| 36 | const LINUX_KEY_ITERATIONS = 1; |
| 37 | /** Chromium's documented fallback passphrase when no Linux keyring is present. */ |
| 38 | const LINUX_FALLBACK_PASSPHRASE = "peanuts"; |
| 39 | |
| 40 | export const ChromiumKeyFailure = Schema.Literals([ |
| 41 | "needsKeychainApproval", |
| 42 | "keychainItemMissing", |
| 43 | "keychainUnavailable", |
| 44 | "unsupportedPlatform", |
| 45 | /** The key store itself could not be read, as opposed to holding no key. */ |
| 46 | "readFailed", |
| 47 | ]); |
| 48 | export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type; |
| 49 | |
| 50 | export class ChromiumKeyError extends Schema.TaggedError<ChromiumKeyError>()("ChromiumKeyError", { |
| 51 | reason: ChromiumKeyFailure, |
| 52 | /** Kept for the log; never surfaced to the user. */ |
| 53 | cause: Schema.optional(Schema.Defect()), |
| 54 | }) { |
| 55 | override get message(): string { |
| 56 | return `Could not obtain the Chromium cookie key: ${this.reason}.`; |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Keys to try, indexed by the record prefix they decrypt. A database can hold |
| 62 | * records written under more than one scheme, so a missing entry means those |
| 63 | * records are skipped rather than the whole import failing. |
| 64 | */ |
| 65 | export interface ChromiumKeyMaterial { |
| 66 | /** AES-128-CBC on macOS, and the keyring-free Linux fallback. */ |
| 67 | readonly cbcV10?: Buffer; |
| 68 | /** AES-128-CBC, Linux keyring-derived. */ |
| 69 | readonly cbcV11?: Buffer; |
| 70 | /** Retained so an import that needs this key can report why it is missing. */ |
| 71 | readonly cbcV11Error?: ChromiumKeyError; |
| 72 | /** |
| 73 | * AES-128-CBC from an empty passphrase. Some Linux clients wrote records |
| 74 | * with it (crbug.com/1195256), so Chromium — and this import — retry with it |
| 75 | * after a record's own key fails. |
| 76 | */ |
| 77 | readonly cbcEmpty?: Buffer; |
| 78 | /** AES-256-GCM key used by pre-App-Bound Chromium on Windows. */ |
| 79 | readonly gcmV10?: Buffer; |
| 80 | } |
| 81 | |
| 82 | const derive = (passphrase: string, iterations: number) => |
| 83 | NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1"); |
| 84 | |
| 85 | /** |
| 86 | * Reads the macOS OSCrypt secret from the login keychain. |
| 87 | * |
| 88 | * Uses the in-process Keychain API rather than shelling out to |
| 89 | * `/usr/bin/security`, because macOS attributes both the consent prompt and the |
| 90 | * resulting ACL entry to the binary that asks. Via the CLI the prompt says |
| 91 | * "security" and "Always Allow" grants trust to a tool every process on the |
| 92 | * machine can invoke; in-process it names this app and the grant belongs to it. |
| 93 | * (In an unsigned dev build the name is the dev binary, not the shipped app |
| 94 | * identity.) |
| 95 | * |
| 96 | * Deliberately untimed: macOS answers this with a modal, and a timeout racing |
| 97 | * the user means the prompt can be approved while nothing is left listening, |
| 98 | * which reads as "approving did nothing". |
| 99 | */ |
| 100 | const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* ( |
| 101 | service: string, |
| 102 | account: string, |
| 103 | ) { |
| 104 | // Only macOS cookie imports need this binding; loading it at startup can |
| 105 | // prevent the desktop from opening on platforms that never use it. |
| 106 | const Keyring = yield* Effect.tryPromise({ |
| 107 | try: () => import("@napi-rs/keyring"), |
| 108 | catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), |
| 109 | }); |
| 110 | const secret = yield* Effect.try({ |
| 111 | try: () => new Keyring.Entry(service, account).getPassword(), |
| 112 | catch: (cause) => { |
| 113 | const message = String((cause as { message?: unknown } | undefined)?.message ?? ""); |
| 114 | // Distinguish the causes rather than reporting "approve the prompt" for |
| 115 | // a failure approving cannot fix. |
| 116 | const missing = /no (matching )?entry|not found/i.test(message); |
| 117 | return new ChromiumKeyError({ |
| 118 | reason: missing ? "keychainItemMissing" : "needsKeychainApproval", |
| 119 | cause, |
| 120 | }); |
| 121 | }, |
| 122 | }); |
| 123 | if (secret === null || secret === "") { |
| 124 | return yield* new ChromiumKeyError({ reason: "keychainItemMissing" }); |
| 125 | } |
| 126 | return secret; |
| 127 | }); |
| 128 | |
| 129 | /** |
| 130 | * The bundled helper searches Chromium's libsecret schema and application |
| 131 | * attribute, retaining the desktop's normal unlock prompt. Its exit codes |
| 132 | * distinguish a missing key, denied access, and an unavailable keyring without |
| 133 | * parsing localized error messages. Stdout is the unmodified secret. |
| 134 | */ |
| 135 | export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* ( |
| 136 | application: string, |
| 137 | ) { |
| 138 | return yield* Effect.scoped( |
| 139 | Effect.gen(function* () { |
| 140 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 141 | const environment = yield* HostProcess.Environment; |
| 142 | const helper = yield* LinuxBrowserSecretPath; |
| 143 | if (helper === undefined) { |
| 144 | return yield* new ChromiumKeyError({ reason: "keychainUnavailable" }); |
| 145 | } |
| 146 | const handle = yield* spawner |
| 147 | .spawn(ChildProcess.make(helper, [application], { stdin: "ignore", env: environment })) |
| 148 | .pipe( |
| 149 | Effect.mapError( |
| 150 | (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), |
| 151 | ), |
| 152 | ); |
| 153 | const [secret, , exitCode] = yield* Effect.all( |
| 154 | [ |
| 155 | handle.stdout.pipe(Stream.decodeText(), Stream.mkString), |
| 156 | handle.stderr.pipe(Stream.runDrain), |
| 157 | handle.exitCode, |
| 158 | ], |
| 159 | { concurrency: "unbounded" }, |
| 160 | ).pipe( |
| 161 | Effect.mapError((cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause })), |
| 162 | ); |
| 163 | if (Number(exitCode) !== 0) { |
| 164 | return yield* new ChromiumKeyError({ |
| 165 | reason: |
| 166 | Number(exitCode) === 2 |
| 167 | ? "keychainItemMissing" |
| 168 | : Number(exitCode) === 3 |
| 169 | ? "needsKeychainApproval" |
| 170 | : "keychainUnavailable", |
| 171 | }); |
| 172 | } |
| 173 | if (secret === "") { |
| 174 | return yield* new ChromiumKeyError({ reason: "keychainItemMissing" }); |
| 175 | } |
| 176 | return secret; |
| 177 | }), |
| 178 | ); |
| 179 | }); |
| 180 | |
| 181 | const WindowsLocalState = Schema.Struct({ |
| 182 | os_crypt: Schema.Struct({ |
| 183 | encrypted_key: Schema.String, |
| 184 | app_bound_encrypted_key: Schema.optional(Schema.String), |
| 185 | }), |
| 186 | }); |
| 187 | const decodeWindowsLocalState = Schema.decodeUnknownEffect( |
| 188 | Schema.fromJsonString(WindowsLocalState), |
| 189 | ); |
| 190 | const DPAPI_PREFIX = Buffer.from("DPAPI"); |
| 191 | const WINDOWS_KEY_LENGTH = 32; |
| 192 | const WINDOWS_DPAPI_SCRIPT = |
| 193 | "Add-Type -AssemblyName System.Security;" + |
| 194 | "$value=[Console]::In.ReadToEnd();" + |
| 195 | "$encrypted=[Convert]::FromBase64String($value);" + |
| 196 | "$plain=[Security.Cryptography.ProtectedData]::Unprotect($encrypted,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);" + |
| 197 | "[Console]::Out.Write([Convert]::ToBase64String($plain))"; |
| 198 | |
| 199 | export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrappedKey")(function* ( |
| 200 | contents: string, |
| 201 | ) { |
| 202 | const state = yield* decodeWindowsLocalState(contents).pipe( |
| 203 | Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })), |
| 204 | ); |
| 205 | if (state.os_crypt.app_bound_encrypted_key !== undefined) { |
| 206 | return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); |
| 207 | } |
| 208 | const wrapped = yield* Effect.fromResult(Base64.decode(state.os_crypt.encrypted_key)).pipe( |
| 209 | Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })), |
| 210 | ); |
| 211 | const wrappedBuffer = Buffer.from(wrapped); |
| 212 | if (!wrappedBuffer.subarray(0, DPAPI_PREFIX.length).equals(DPAPI_PREFIX)) { |
| 213 | return yield* new ChromiumKeyError({ reason: "readFailed" }); |
| 214 | } |
| 215 | return wrappedBuffer.subarray(DPAPI_PREFIX.length); |
| 216 | }); |
| 217 | |
| 218 | /** Unwraps a key with the current Windows user's DPAPI identity. */ |
| 219 | export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* ( |
| 220 | wrapped: Buffer, |
| 221 | ) { |
| 222 | const environment = yield* HostProcess.Environment; |
| 223 | return yield* Effect.scoped( |
| 224 | Effect.gen(function* () { |
| 225 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; |
| 226 | const windowsRoot = environment.SystemRoot ?? environment.WINDIR; |
| 227 | const powershell = windowsRoot |
| 228 | ? `${windowsRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe` |
| 229 | : "powershell.exe"; |
| 230 | const handle = yield* spawner |
| 231 | .spawn( |
| 232 | ChildProcess.make( |
| 233 | powershell, |
| 234 | [ |
| 235 | "-NoLogo", |
| 236 | "-NoProfile", |
| 237 | "-NonInteractive", |
| 238 | "-WindowStyle", |
| 239 | "Hidden", |
| 240 | "-Command", |
| 241 | WINDOWS_DPAPI_SCRIPT, |
| 242 | ], |
| 243 | { |
| 244 | env: environment, |
| 245 | stdin: Stream.encodeText(Stream.make(wrapped.toString("base64"))), |
| 246 | }, |
| 247 | ), |
| 248 | ) |
| 249 | .pipe( |
| 250 | Effect.mapError( |
| 251 | (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }), |
| 252 | ), |
| 253 | ); |
| 254 | const [plainEncoded, , exitCode] = yield* Effect.all( |
| 255 | [ |
| 256 | handle.stdout.pipe(Stream.decodeText(), Stream.mkString), |
| 257 | handle.stderr.pipe(Stream.runDrain), |
| 258 | handle.exitCode, |
| 259 | ], |
| 260 | { concurrency: "unbounded" }, |
| 261 | ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); |
| 262 | if (Number(exitCode) !== 0) { |
| 263 | return yield* new ChromiumKeyError({ reason: "readFailed" }); |
| 264 | } |
| 265 | const plain = yield* Effect.fromResult(Base64.decode(plainEncoded)).pipe( |
| 266 | Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })), |
| 267 | ); |
| 268 | if (plain.length !== WINDOWS_KEY_LENGTH) { |
| 269 | return yield* new ChromiumKeyError({ reason: "readFailed" }); |
| 270 | } |
| 271 | return Buffer.from(plain); |
| 272 | }), |
| 273 | ); |
| 274 | }); |
| 275 | |
| 276 | /** Reads and unwraps a legacy Windows Chromium key without exposing it in argv. */ |
| 277 | export const readWindowsKey = Effect.fn("ChromiumKeys.readWindowsKey")(function* ( |
| 278 | localStatePath: string, |
| 279 | ) { |
| 280 | const fileSystem = yield* FileSystem.FileSystem; |
| 281 | const contents = yield* fileSystem |
| 282 | .readFileString(localStatePath) |
| 283 | .pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause }))); |
| 284 | return yield* unwrapWindowsDpapiKey(yield* decodeWindowsWrappedKey(contents)); |
| 285 | }); |
| 286 | |
| 287 | export interface ChromiumKeyRequest { |
| 288 | readonly platform: NodeJS.Platform; |
| 289 | readonly keychainService: string | undefined; |
| 290 | readonly keychainAccount: string | undefined; |
| 291 | readonly linuxSecretApplication: string | undefined; |
| 292 | } |
| 293 | |
| 294 | export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* ( |
| 295 | request: ChromiumKeyRequest, |
| 296 | ): Effect.fn.Return< |
| 297 | ChromiumKeyMaterial, |
| 298 | ChromiumKeyError, |
| 299 | ChildProcessSpawner.ChildProcessSpawner |
| 300 | > { |
| 301 | if (request.platform === "darwin") { |
| 302 | if (!request.keychainService || !request.keychainAccount) { |
| 303 | return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); |
| 304 | } |
| 305 | const secret = yield* readKeychainSecret(request.keychainService, request.keychainAccount); |
| 306 | return { cbcV10: derive(secret, MAC_KEY_ITERATIONS) }; |
| 307 | } |
| 308 | |
| 309 | if (request.platform === "linux") { |
| 310 | // The fallback passphrase always applies to `v10` records; a keyring |
| 311 | // secret, when one is reachable, additionally unlocks `v11`. Preserve its |
| 312 | // failure until the reader knows whether any cookies needed that key. |
| 313 | const keyringSecret = request.linuxSecretApplication |
| 314 | ? yield* readLinuxSecret(request.linuxSecretApplication).pipe( |
| 315 | // v10 remains importable when Secret Service is absent or does not |
| 316 | // contain a key. An explicit denial/lock/cancel remains a consent |
| 317 | // failure rather than being silently downgraded. |
| 318 | Effect.catchIf( |
| 319 | (error) => error.reason !== "needsKeychainApproval", |
| 320 | (error) => Effect.succeed(error), |
| 321 | ), |
| 322 | ) |
| 323 | : undefined; |
| 324 | return { |
| 325 | cbcV10: derive(LINUX_FALLBACK_PASSPHRASE, LINUX_KEY_ITERATIONS), |
| 326 | ...(typeof keyringSecret === "string" |
| 327 | ? { cbcV11: derive(keyringSecret, LINUX_KEY_ITERATIONS) } |
| 328 | : keyringSecret |
| 329 | ? { cbcV11Error: keyringSecret } |
| 330 | : {}), |
| 331 | cbcEmpty: derive("", LINUX_KEY_ITERATIONS), |
| 332 | }; |
| 333 | } |
| 334 | |
| 335 | return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" }); |
| 336 | }); |