| 1 | // @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the |
| 2 | // OSCrypt primitives Chromium uses; Effect has no equivalent. |
| 3 | /** |
| 4 | * Chromium cookie extraction. |
| 5 | * |
| 6 | * Reads a Chromium-family browser's cookie database and decrypts each record |
| 7 | * with the key its prefix calls for. Key acquisition — and the consent it |
| 8 | * needs — lives in `ChromiumKeys`. |
| 9 | * |
| 10 | * Records whose scheme we hold no key for are skipped rather than failing the |
| 11 | * whole import: a Linux database can mix `v10` and `v11`. A partial result |
| 12 | * reported honestly is more useful than an all-or-nothing error. |
| 13 | * |
| 14 | * @module ChromiumCookies |
| 15 | */ |
| 16 | import * as NodeCrypto from "node:crypto"; |
| 17 | |
| 18 | import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; |
| 19 | import * as Effect from "effect/Effect"; |
| 20 | import * as FileSystem from "effect/FileSystem"; |
| 21 | import * as Path from "effect/Path"; |
| 22 | import * as Schema from "effect/Schema"; |
| 23 | import * as Scope from "effect/Scope"; |
| 24 | import * as SqlClient from "effect/sql/SqlClient"; |
| 25 | import { ChildProcessSpawner } from "effect/process"; |
| 26 | |
| 27 | import { |
| 28 | ChromiumKeyError, |
| 29 | ChromiumKeyFailure, |
| 30 | readWindowsKey, |
| 31 | resolveChromiumKeys, |
| 32 | type ChromiumKeyMaterial, |
| 33 | } from "./ChromiumKeys.ts"; |
| 34 | import { |
| 35 | bareHost, |
| 36 | cookieScope, |
| 37 | snapshotCookieDatabase, |
| 38 | type CookieReadResult, |
| 39 | type ImportedCookie, |
| 40 | } from "./CookieDatabase.ts"; |
| 41 | |
| 42 | /** OSCrypt's CBC mode uses a fixed IV of 16 spaces rather than a per-record one. */ |
| 43 | const AES_CBC_IV = Buffer.alloc(16, 0x20); |
| 44 | const AES_GCM_NONCE_LENGTH = 12; |
| 45 | const AES_GCM_TAG_LENGTH = 16; |
| 46 | const isChromiumKeyError = Schema.is(ChromiumKeyError); |
| 47 | |
| 48 | /** |
| 49 | * Every way the read can fail: the key failures, plus the ones this module |
| 50 | * raises itself. |
| 51 | */ |
| 52 | export const ChromiumCookieReadReason = Schema.Literals([ |
| 53 | // `readFailed` already comes from the key failures, so it is not repeated. |
| 54 | ...ChromiumKeyFailure.literals, |
| 55 | "browserRunning", |
| 56 | ]); |
| 57 | export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type; |
| 58 | |
| 59 | export class ChromiumCookieReadError extends Schema.TaggedError<ChromiumCookieReadError>()( |
| 60 | "ChromiumCookieReadError", |
| 61 | { |
| 62 | reason: ChromiumCookieReadReason, |
| 63 | /** |
| 64 | * Which database the read was for. Without it every `readFailed` and |
| 65 | * keychain failure logs identically, and a user with several browsers |
| 66 | * installed has no way to tell which one refused. |
| 67 | */ |
| 68 | cookieDatabasePath: Schema.String, |
| 69 | /** Kept for the log; never surfaced to the user. */ |
| 70 | cause: Schema.optional(Schema.Defect()), |
| 71 | }, |
| 72 | ) { |
| 73 | override get message(): string { |
| 74 | return `Could not read Chromium cookies at ${this.cookieDatabasePath}: ${this.reason}.`; |
| 75 | } |
| 76 | } |
| 77 | |
| 78 | /** Row shape of the cookie table, decoded rather than cast. */ |
| 79 | const CookieRow = Schema.Struct({ |
| 80 | host_key: Schema.String, |
| 81 | name: Schema.String, |
| 82 | value: Schema.String, |
| 83 | encrypted_value: Schema.Uint8Array, |
| 84 | path: Schema.String, |
| 85 | expires_seconds: Schema.Number, |
| 86 | is_secure: Schema.Number, |
| 87 | is_httponly: Schema.Number, |
| 88 | samesite: Schema.Number, |
| 89 | top_frame_site_key: Schema.String, |
| 90 | }); |
| 91 | const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow)); |
| 92 | const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); |
| 93 | const SchemaVersion = Schema.Union([ |
| 94 | NonNegativeInt, |
| 95 | Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))), |
| 96 | ]); |
| 97 | const decodeSchemaVersion = Schema.decodeUnknownEffect( |
| 98 | Schema.Tuple([Schema.Struct({ value: SchemaVersion })]), |
| 99 | ); |
| 100 | |
| 101 | /** |
| 102 | * Chromium stores `SameSite` as an int: -1 = unspecified, 0 = none, 1 = lax, |
| 103 | * 2 = strict. Unspecified is imported as Electron's own `unspecified` rather |
| 104 | * than pinned to Lax, so the target browser applies its default just as the |
| 105 | * source did; anything unrecognised lands there too, since guessing "none" |
| 106 | * would widen a cookie's scope on import. |
| 107 | */ |
| 108 | const sameSiteFromColumn = (value: number): ImportedCookie["sameSite"] => { |
| 109 | if (value === 0) return "no_restriction"; |
| 110 | if (value === 1) return "lax"; |
| 111 | if (value === 2) return "strict"; |
| 112 | return "unspecified"; |
| 113 | }; |
| 114 | |
| 115 | /** |
| 116 | * Chromium timestamps count microseconds from 1601-01-01; Electron wants |
| 117 | * seconds from the UNIX epoch. The microsecond value overflows JavaScript's |
| 118 | * safe integer range and `node:sqlite` refuses to narrow it, so the division |
| 119 | * happens in SQL and this only ever sees seconds. |
| 120 | */ |
| 121 | const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600; |
| 122 | const toUnixSeconds = (webkitSeconds: number): number | undefined => { |
| 123 | if (webkitSeconds <= 0) return undefined; |
| 124 | return webkitSeconds - WEBKIT_EPOCH_OFFSET_SECONDS; |
| 125 | }; |
| 126 | |
| 127 | /** |
| 128 | * Chromium >= 127 prefixes the plaintext with SHA-256 of the host key, binding |
| 129 | * a cookie to its domain. Strip it when present. |
| 130 | */ |
| 131 | const stripDomainBinding = ( |
| 132 | plaintext: Buffer, |
| 133 | domain: string, |
| 134 | schemaVersion: number, |
| 135 | ): Buffer | null => { |
| 136 | if (schemaVersion < 24) return plaintext; |
| 137 | const domainHash = NodeCrypto.createHash("sha256").update(domain).digest(); |
| 138 | return plaintext.length >= 32 && plaintext.subarray(0, 32).equals(domainHash) |
| 139 | ? plaintext.subarray(32) |
| 140 | : null; |
| 141 | }; |
| 142 | |
| 143 | const decryptCbc = ( |
| 144 | payload: Buffer, |
| 145 | key: Buffer, |
| 146 | domain: string, |
| 147 | schemaVersion: number, |
| 148 | ): string | null => { |
| 149 | try { |
| 150 | const decipher = NodeCrypto.createDecipheriv("aes-128-cbc", key, AES_CBC_IV); |
| 151 | decipher.setAutoPadding(true); |
| 152 | const plaintext = Buffer.concat([decipher.update(payload), decipher.final()]); |
| 153 | return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null; |
| 154 | } catch { |
| 155 | return null; |
| 156 | } |
| 157 | }; |
| 158 | |
| 159 | const decryptGcm = ( |
| 160 | payload: Buffer, |
| 161 | key: Buffer, |
| 162 | domain: string, |
| 163 | schemaVersion: number, |
| 164 | ): string | null => { |
| 165 | if (payload.length < AES_GCM_NONCE_LENGTH + AES_GCM_TAG_LENGTH) return null; |
| 166 | try { |
| 167 | const nonce = payload.subarray(0, AES_GCM_NONCE_LENGTH); |
| 168 | const ciphertext = payload.subarray(AES_GCM_NONCE_LENGTH, -AES_GCM_TAG_LENGTH); |
| 169 | const tag = payload.subarray(-AES_GCM_TAG_LENGTH); |
| 170 | const decipher = NodeCrypto.createDecipheriv("aes-256-gcm", key, nonce); |
| 171 | decipher.setAuthTag(tag); |
| 172 | const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]); |
| 173 | return stripDomainBinding(plaintext, domain, schemaVersion)?.toString("utf8") ?? null; |
| 174 | } catch { |
| 175 | return null; |
| 176 | } |
| 177 | }; |
| 178 | |
| 179 | /** |
| 180 | * Decrypts one stored value, choosing the scheme from its prefix. Returns null |
| 181 | * when no key covers that scheme — including Windows' app-bound `v20`, which |
| 182 | * this build has no key for at all. |
| 183 | */ |
| 184 | export function decryptChromiumValue( |
| 185 | encrypted: Uint8Array, |
| 186 | keys: ChromiumKeyMaterial, |
| 187 | domain: string, |
| 188 | schemaVersion = 23, |
| 189 | platform: NodeJS.Platform = "linux", |
| 190 | ): string | null { |
| 191 | const buffer = Buffer.from(encrypted); |
| 192 | if (buffer.length === 0) return ""; |
| 193 | const prefix = buffer.subarray(0, 3).toString("latin1"); |
| 194 | const payload = buffer.subarray(3); |
| 195 | |
| 196 | // Windows' legacy v10 format is AES-256-GCM. App-bound records use v20 and |
| 197 | // intentionally have no key here, so they fall through as undecryptable. |
| 198 | if (platform === "win32") { |
| 199 | return prefix === "v10" && keys.gcmV10 |
| 200 | ? decryptGcm(payload, keys.gcmV10, domain, schemaVersion) |
| 201 | : null; |
| 202 | } |
| 203 | |
| 204 | // Chromium retries a failed record with a key derived from an empty |
| 205 | // passphrase, because some Linux clients wrote data that way |
| 206 | // (crbug.com/1195256). A record whose own key is missing entirely stays |
| 207 | // skipped, matching Chromium. |
| 208 | if (prefix === "v10") { |
| 209 | if (!keys.cbcV10) return null; |
| 210 | return ( |
| 211 | decryptCbc(payload, keys.cbcV10, domain, schemaVersion) ?? |
| 212 | (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null) |
| 213 | ); |
| 214 | } |
| 215 | if (prefix === "v11") { |
| 216 | if (!keys.cbcV11) return null; |
| 217 | return ( |
| 218 | decryptCbc(payload, keys.cbcV11, domain, schemaVersion) ?? |
| 219 | (keys.cbcEmpty ? decryptCbc(payload, keys.cbcEmpty, domain, schemaVersion) : null) |
| 220 | ); |
| 221 | } |
| 222 | // No recognised prefix: Chromium on macOS and Linux both treat this as |
| 223 | // legacy data stored in the clear and return it as-is, so it is a readable |
| 224 | // cookie rather than an undecryptable one. Windows is the exception — its |
| 225 | // app-bound `v20` blobs also lack these prefixes and must not be read as |
| 226 | // plaintext — but Windows Chromium is not importable here at all. |
| 227 | if (platform === "darwin" || platform === "linux") { |
| 228 | return stripDomainBinding(buffer, domain, schemaVersion)?.toString("utf8") ?? null; |
| 229 | } |
| 230 | return null; |
| 231 | } |
| 232 | |
| 233 | /** Reads and decodes one snapshotted Chromium cookie database. */ |
| 234 | export const readChromiumCookieDatabase = Effect.fn("ChromiumCookies.readChromiumCookieDatabase")( |
| 235 | function* (snapshotPath: string, keys: ChromiumKeyMaterial, platform: NodeJS.Platform) { |
| 236 | const result = yield* Effect.gen(function* () { |
| 237 | const sql = yield* SqlClient.SqlClient; |
| 238 | const schemaVersion = yield* sql`select value from meta where key = 'version' limit 1`.pipe( |
| 239 | Effect.flatMap(decodeSchemaVersion), |
| 240 | Effect.map(([row]) => row.value), |
| 241 | ); |
| 242 | const raw = |
| 243 | schemaVersion >= 15 |
| 244 | ? yield* sql`select host_key, name, value, encrypted_value, path, |
| 245 | expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, |
| 246 | samesite, top_frame_site_key from cookies` |
| 247 | : yield* sql`select host_key, name, value, encrypted_value, path, |
| 248 | expires_utc / 1000000 as expires_seconds, is_secure, is_httponly, |
| 249 | samesite, '' as top_frame_site_key from cookies`; |
| 250 | return { rows: yield* decodeCookieRows(raw), schemaVersion }; |
| 251 | }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: snapshotPath, readonly: true }))); |
| 252 | |
| 253 | const cookies: ImportedCookie[] = []; |
| 254 | let undecryptable = 0; |
| 255 | const undecryptableHosts = new Set<string>(); |
| 256 | for (const row of result.rows) { |
| 257 | if (row.top_frame_site_key !== "") { |
| 258 | undecryptable += 1; |
| 259 | undecryptableHosts.add(bareHost(row.host_key)); |
| 260 | continue; |
| 261 | } |
| 262 | const value = |
| 263 | row.encrypted_value.length === 0 |
| 264 | ? row.value |
| 265 | : decryptChromiumValue( |
| 266 | row.encrypted_value, |
| 267 | keys, |
| 268 | row.host_key, |
| 269 | result.schemaVersion, |
| 270 | platform, |
| 271 | ); |
| 272 | if (value === null) { |
| 273 | undecryptable += 1; |
| 274 | undecryptableHosts.add(bareHost(row.host_key)); |
| 275 | continue; |
| 276 | } |
| 277 | const secure = row.is_secure === 1; |
| 278 | const scope = cookieScope(row.host_key, row.path, secure); |
| 279 | cookies.push({ |
| 280 | url: scope.url, |
| 281 | name: row.name, |
| 282 | value, |
| 283 | domain: scope.domain, |
| 284 | path: row.path, |
| 285 | secure, |
| 286 | httpOnly: row.is_httponly === 1, |
| 287 | expirationDate: toUnixSeconds(row.expires_seconds), |
| 288 | sameSite: sameSiteFromColumn(row.samesite), |
| 289 | }); |
| 290 | } |
| 291 | // Keep partial imports, but do not call a missing key a successful import |
| 292 | // when it prevented every otherwise importable cookie from being read. |
| 293 | if ( |
| 294 | cookies.length === 0 && |
| 295 | keys.cbcV11Error !== undefined && |
| 296 | result.rows.some( |
| 297 | (row) => |
| 298 | row.top_frame_site_key === "" && |
| 299 | Buffer.from(row.encrypted_value.subarray(0, 3)).toString("latin1") === "v11", |
| 300 | ) |
| 301 | ) { |
| 302 | return yield* keys.cbcV11Error; |
| 303 | } |
| 304 | return { |
| 305 | cookies, |
| 306 | undecryptable, |
| 307 | undecryptableHosts: [...undecryptableHosts], |
| 308 | } satisfies CookieReadResult; |
| 309 | }, |
| 310 | ); |
| 311 | |
| 312 | export interface ChromiumCookieSource { |
| 313 | readonly cookieDatabasePath: string; |
| 314 | readonly keychainService: string | undefined; |
| 315 | readonly keychainAccount: string | undefined; |
| 316 | readonly linuxSecretApplication: string | undefined; |
| 317 | readonly windowsLocalStatePath?: string; |
| 318 | /** Supplied by the caller from `HostProcess.Platform` rather than read here. */ |
| 319 | readonly platform: NodeJS.Platform; |
| 320 | } |
| 321 | |
| 322 | export const readChromiumCookies = Effect.fn("ChromiumCookies.readChromiumCookies")(function* ( |
| 323 | source: ChromiumCookieSource, |
| 324 | ): Effect.fn.Return< |
| 325 | CookieReadResult, |
| 326 | ChromiumCookieReadError, |
| 327 | FileSystem.FileSystem | Path.Path | Scope.Scope | ChildProcessSpawner.ChildProcessSpawner |
| 328 | > { |
| 329 | const keys = yield* ( |
| 330 | source.platform === "win32" && source.windowsLocalStatePath |
| 331 | ? readWindowsKey(source.windowsLocalStatePath).pipe(Effect.map((gcmV10) => ({ gcmV10 }))) |
| 332 | : resolveChromiumKeys({ |
| 333 | platform: source.platform, |
| 334 | keychainService: source.keychainService, |
| 335 | keychainAccount: source.keychainAccount, |
| 336 | linuxSecretApplication: source.linuxSecretApplication, |
| 337 | }) |
| 338 | ).pipe( |
| 339 | Effect.mapError( |
| 340 | (cause: ChromiumKeyError) => |
| 341 | new ChromiumCookieReadError({ |
| 342 | reason: cause.reason, |
| 343 | cookieDatabasePath: source.cookieDatabasePath, |
| 344 | cause, |
| 345 | }), |
| 346 | ), |
| 347 | ); |
| 348 | |
| 349 | const snapshotPath = yield* snapshotCookieDatabase(source.cookieDatabasePath).pipe( |
| 350 | Effect.mapError( |
| 351 | (cause) => |
| 352 | new ChromiumCookieReadError({ |
| 353 | reason: "readFailed", |
| 354 | cookieDatabasePath: source.cookieDatabasePath, |
| 355 | cause, |
| 356 | }), |
| 357 | ), |
| 358 | ); |
| 359 | |
| 360 | return yield* readChromiumCookieDatabase(snapshotPath, keys, source.platform).pipe( |
| 361 | Effect.mapError( |
| 362 | (cause) => |
| 363 | new ChromiumCookieReadError({ |
| 364 | reason: isChromiumKeyError(cause) ? cause.reason : "readFailed", |
| 365 | cookieDatabasePath: source.cookieDatabasePath, |
| 366 | cause, |
| 367 | }), |
| 368 | ), |
| 369 | ); |
| 370 | }); |