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 */
16import * as NodeCrypto from "node:crypto";
17
18import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient";
19import * as Effect from "effect/Effect";
20import * as FileSystem from "effect/FileSystem";
21import * as Path from "effect/Path";
22import * as Schema from "effect/Schema";
23import * as Scope from "effect/Scope";
24import * as SqlClient from "effect/sql/SqlClient";
25import { ChildProcessSpawner } from "effect/process";
26
27import {
28 ChromiumKeyError,
29 ChromiumKeyFailure,
30 readWindowsKey,
31 resolveChromiumKeys,
32 type ChromiumKeyMaterial,
33} from "./ChromiumKeys.ts";
34import {
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. */
43const AES_CBC_IV = Buffer.alloc(16, 0x20);
44const AES_GCM_NONCE_LENGTH = 12;
45const AES_GCM_TAG_LENGTH = 16;
46const 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 */
52export const ChromiumCookieReadReason = Schema.Literals([
53 // `readFailed` already comes from the key failures, so it is not repeated.
54 ...ChromiumKeyFailure.literals,
55 "browserRunning",
56]);
57export type ChromiumCookieReadReason = typeof ChromiumCookieReadReason.Type;
58
59export 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. */
79const 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});
91const decodeCookieRows = Schema.decodeUnknownEffect(Schema.Array(CookieRow));
92const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
93const SchemaVersion = Schema.Union([
94 NonNegativeInt,
95 Schema.FiniteFromString.pipe(Schema.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
96]);
97const 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 */
108const 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 */
121const WEBKIT_EPOCH_OFFSET_SECONDS = 11_644_473_600;
122const 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 */
131const 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
143const 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
159const 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 */
184export 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. */
234export 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
312export 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
322export 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});