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 */
20import * as NodeCrypto from "node:crypto";
21
22import * as HostProcess from "@t3tools/shared/HostProcess";
23
24import * as Effect from "effect/Effect";
25import * as Base64 from "effect/encoding/Base64";
26import * as FileSystem from "effect/FileSystem";
27import * as Schema from "effect/Schema";
28import * as Stream from "effect/Stream";
29import { ChildProcess, ChildProcessSpawner } from "effect/process";
30import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
31
32const KEY_SALT = "saltysalt";
33const KEY_LENGTH = 16;
34/** macOS stretches the keychain secret; Linux uses a single iteration. */
35const MAC_KEY_ITERATIONS = 1003;
36const LINUX_KEY_ITERATIONS = 1;
37/** Chromium's documented fallback passphrase when no Linux keyring is present. */
38const LINUX_FALLBACK_PASSPHRASE = "peanuts";
39
40export 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]);
48export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type;
49
50export 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 */
65export 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
82const 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 */
100const 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 */
135export 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
181const WindowsLocalState = Schema.Struct({
182 os_crypt: Schema.Struct({
183 encrypted_key: Schema.String,
184 app_bound_encrypted_key: Schema.optional(Schema.String),
185 }),
186});
187const decodeWindowsLocalState = Schema.decodeUnknownEffect(
188 Schema.fromJsonString(WindowsLocalState),
189);
190const DPAPI_PREFIX = Buffer.from("DPAPI");
191const WINDOWS_KEY_LENGTH = 32;
192const 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
199export 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. */
219export 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. */
277export 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
287export interface ChromiumKeyRequest {
288 readonly platform: NodeJS.Platform;
289 readonly keychainService: string | undefined;
290 readonly keychainAccount: string | undefined;
291 readonly linuxSecretApplication: string | undefined;
292}
293
294export 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});