Skip to content
Source
Sign inSign up
Open History to see the last commit

ChromiumKeys.ts

275 lines10.2 KB

1// @effect-diagnostics nodeBuiltinImport:off - `node:crypto` implements the
2// OSCrypt key derivation Chromium uses; Effect has no equivalent.
3import * as NodeCrypto from "node:crypto";
4​
5import * as HostProcess from "@t3tools/shared/HostProcess";
6​
7import * as Effect from "effect/Effect";
8import * as Base64 from "effect/encoding/Base64";
9import * as FileSystem from "effect/FileSystem";
10import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";
12import { ChildProcess, ChildProcessSpawner } from "effect/process";
13import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
14​
15const KEY_SALT = "saltysalt";
16const KEY_LENGTH = 16;
17const MAC_KEY_ITERATIONS = 1003;
18const LINUX_KEY_ITERATIONS = 1;
19/** Chromium's documented fallback passphrase when no Linux keyring is present. */
20const LINUX_FALLBACK_PASSPHRASE = "peanuts";
21​
22export const ChromiumKeyFailure = Schema.Literals([
23 "needsKeychainApproval",
24 "keychainItemMissing",
25 "keychainUnavailable",
26 "unsupportedPlatform",
27 "readFailed",
28]);
29export type ChromiumKeyFailure = typeof ChromiumKeyFailure.Type;
30​
31export class ChromiumKeyError extends Schema.TaggedError<ChromiumKeyError>()("ChromiumKeyError", {
32 reason: ChromiumKeyFailure,
33 cause: Schema.optional(Schema.Defect()),
34}) {
35 override get message(): string {
36 return `Could not obtain the Chromium cookie key: ${this.reason}.`;
37 }
39​
40export interface ChromiumKeyMaterial {
41 readonly cbcV10?: Buffer;
42 readonly cbcV11?: Buffer;
43 readonly cbcV11Error?: ChromiumKeyError;
44 /** Linux clients can encrypt with an empty passphrase; retry it on failure (crbug.com/1195256). */
45 readonly cbcEmpty?: Buffer;
46 readonly gcmV10?: Buffer;
48​
49const derive = (passphrase: string, iterations: number) =>
50 NodeCrypto.pbkdf2Sync(passphrase, KEY_SALT, iterations, KEY_LENGTH, "sha1");
51​
52// In-process Keychain access attributes grants to this app. Keep modal prompts untimed to preserve their listener.
53const readKeychainSecret = Effect.fn("ChromiumKeys.readKeychainSecret")(function* (
54 service: string,
55 account: string,
56) {
57 // Loading the macOS binding at startup can prevent other platforms from opening.
58 const Keyring = yield* Effect.tryPromise({
59 try: () => import("@napi-rs/keyring"),
60 catch: (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),
61 });
62 const secret = yield* Effect.tryPromise({
63 try: () => new Keyring.AsyncEntry(service, account).getPassword(),
64 catch: (cause) => {
65 const message = String((cause as { message?: unknown } | undefined)?.message ?? "");
66 const missing = /no (matching )?entry|not found/i.test(message);
67 return new ChromiumKeyError({
68 reason: missing ? "keychainItemMissing" : "needsKeychainApproval",
69 cause,
70 });
71 },
72 });
73 if (!secret) {
74 return yield* new ChromiumKeyError({ reason: "keychainItemMissing" });
75 }
76 return secret;
77});
78​
79/** Exit codes distinguish missing keys, denied access, and unavailable keyrings without localized message parsing. */
80export const readLinuxSecret = Effect.fn("ChromiumKeys.readLinuxSecret")(function* (
81 application: string,
82) {
83 return yield* Effect.scoped(
84 Effect.gen(function* () {
85 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
86 const environment = yield* HostProcess.Environment;
87 const helper = yield* LinuxBrowserSecretPath;
88 if (helper === undefined) {
89 return yield* new ChromiumKeyError({ reason: "keychainUnavailable" });
90 }
91 const handle = yield* spawner
92 .spawn(ChildProcess.make(helper, [application], { stdin: "ignore", env: environment }))
93 .pipe(
94 Effect.mapError(
95 (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),
96 ),
97 );
98 const [secret, , exitCode] = yield* Effect.all(
99 [
100 handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
101 handle.stderr.pipe(Stream.runDrain),
102 handle.exitCode,
103 ],
104 { concurrency: "unbounded" },
105 ).pipe(
106 Effect.mapError((cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause })),
107 );
108 if (Number(exitCode) !== 0) {
109 return yield* new ChromiumKeyError({
110 reason:
111 Number(exitCode) === 2
112 ? "keychainItemMissing"
113 : Number(exitCode) === 3
114 ? "needsKeychainApproval"
115 : "keychainUnavailable",
116 });
117 }
118 if (secret === "") {
119 return yield* new ChromiumKeyError({ reason: "keychainItemMissing" });
120 }
121 return secret;
122 }),
123 );
124});
125​
126const WindowsLocalState = Schema.Struct({
127 os_crypt: Schema.Struct({
128 encrypted_key: Schema.String,
129 app_bound_encrypted_key: Schema.optional(Schema.String),
130 }),
131});
132const decodeWindowsLocalState = Schema.decodeUnknownEffect(
133 Schema.fromJsonString(WindowsLocalState),
134);
135const DPAPI_PREFIX = Buffer.from("DPAPI");
136const WINDOWS_KEY_LENGTH = 32;
137const WINDOWS_DPAPI_SCRIPT =
138 "Add-Type -AssemblyName System.Security;" +
139 "$value=[Console]::In.ReadToEnd();" +
140 "$encrypted=[Convert]::FromBase64String($value);" +
141 "$plain=[Security.Cryptography.ProtectedData]::Unprotect($encrypted,$null,[Security.Cryptography.DataProtectionScope]::CurrentUser);" +
142 "[Console]::Out.Write([Convert]::ToBase64String($plain))";
143​
144export const decodeWindowsWrappedKey = Effect.fn("ChromiumKeys.decodeWindowsWrappedKey")(function* (
145 contents: string,
146) {
147 const state = yield* decodeWindowsLocalState(contents).pipe(
148 Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),
149 );
150 if (state.os_crypt.app_bound_encrypted_key !== undefined) {
151 return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" });
152 }
153 const wrapped = yield* Effect.fromResult(Base64.decode(state.os_crypt.encrypted_key)).pipe(
154 Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),
155 );
156 const wrappedBuffer = Buffer.from(wrapped);
157 if (!wrappedBuffer.subarray(0, DPAPI_PREFIX.length).equals(DPAPI_PREFIX)) {
158 return yield* new ChromiumKeyError({ reason: "readFailed" });
159 }
160 return wrappedBuffer.subarray(DPAPI_PREFIX.length);
161});
162​
163export const unwrapWindowsDpapiKey = Effect.fn("ChromiumKeys.unwrapWindowsDpapiKey")(function* (
164 wrapped: Buffer,
165) {
166 const environment = yield* HostProcess.Environment;
167 return yield* Effect.scoped(
168 Effect.gen(function* () {
169 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
170 const windowsRoot = environment.SystemRoot ?? environment.WINDIR;
171 const powershell = windowsRoot
172 ? `${windowsRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`
173 : "powershell.exe";
174 const handle = yield* spawner
175 .spawn(
176 ChildProcess.make(
177 powershell,
178 [
179 "-NoLogo",
180 "-NoProfile",
181 "-NonInteractive",
182 "-WindowStyle",
183 "Hidden",
184 "-Command",
185 WINDOWS_DPAPI_SCRIPT,
186 ],
187 {
188 env: environment,
189 stdin: Stream.encodeText(Stream.make(wrapped.toString("base64"))),
190 },
191 ),
192 )
193 .pipe(
194 Effect.mapError(
195 (cause) => new ChromiumKeyError({ reason: "keychainUnavailable", cause }),
196 ),
197 );
198 const [plainEncoded, , exitCode] = yield* Effect.all(
199 [
200 handle.stdout.pipe(Stream.decodeText(), Stream.mkString),
201 handle.stderr.pipe(Stream.runDrain),
202 handle.exitCode,
203 ],
204 { concurrency: "unbounded" },
205 ).pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));
206 if (Number(exitCode) !== 0) {
207 return yield* new ChromiumKeyError({ reason: "readFailed" });
208 }
209 const plain = yield* Effect.fromResult(Base64.decode(plainEncoded)).pipe(
210 Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })),
211 );
212 if (plain.length !== WINDOWS_KEY_LENGTH) {
213 return yield* new ChromiumKeyError({ reason: "readFailed" });
214 }
215 return Buffer.from(plain);
216 }),
217 );
218});
219​
220export const readWindowsKey = Effect.fn("ChromiumKeys.readWindowsKey")(function* (
221 localStatePath: string,
222) {
223 const fileSystem = yield* FileSystem.FileSystem;
224 const contents = yield* fileSystem
225 .readFileString(localStatePath)
226 .pipe(Effect.mapError((cause) => new ChromiumKeyError({ reason: "readFailed", cause })));
227 return yield* unwrapWindowsDpapiKey(yield* decodeWindowsWrappedKey(contents));
228});
229​
230export interface ChromiumKeyRequest {
231 readonly platform: NodeJS.Platform;
232 readonly keychainService: string | undefined;
233 readonly keychainAccount: string | undefined;
234 readonly linuxSecretApplication: string | undefined;
236​
237export const resolveChromiumKeys = Effect.fn("ChromiumKeys.resolveChromiumKeys")(function* (
238 request: ChromiumKeyRequest,
239): Effect.fn.Return<
240 ChromiumKeyMaterial,
241 ChromiumKeyError,
242 ChildProcessSpawner.ChildProcessSpawner
243> {
244 if (request.platform === "darwin") {
245 if (!request.keychainService || !request.keychainAccount) {
246 return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" });
247 }
248 const secret = yield* readKeychainSecret(request.keychainService, request.keychainAccount);
249 return { cbcV10: derive(secret, MAC_KEY_ITERATIONS) };
250 }
251​
252 if (request.platform === "linux") {
253 // v10 always uses the fallback passphrase; a keyring secret additionally unlocks v11.
254 const keyringSecret = request.linuxSecretApplication
255 ? yield* readLinuxSecret(request.linuxSecretApplication).pipe(
256 // Missing Secret Service or keys must not block v10; explicit denial, lock, or cancellation remains a consent failure.
257 Effect.catchIf(
258 (error) => error.reason !== "needsKeychainApproval",
259 (error) => Effect.succeed(error),
260 ),
261 )
262 : undefined;
263 return {
264 cbcV10: derive(LINUX_FALLBACK_PASSPHRASE, LINUX_KEY_ITERATIONS),
265 ...(typeof keyringSecret === "string"
266 ? { cbcV11: derive(keyringSecret, LINUX_KEY_ITERATIONS) }
267 : keyringSecret
268 ? { cbcV11Error: keyringSecret }
269 : {}),
270 cbcEmpty: derive("", LINUX_KEY_ITERATIONS),
271 };
272 }
273​
274 return yield* new ChromiumKeyError({ reason: "unsupportedPlatform" });
275});