1import { describe, expect, it } from "@effect/vitest";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Deferred from "effect/Deferred";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import * as PlatformError from "effect/PlatformError";
9import { ChildProcessSpawner } from "effect/process";
10import { beforeEach, vi } from "vite-plus/test";
11
12import {
13 ChromiumKeyError,
14 decodeWindowsWrappedKey,
15 readLinuxSecret,
16 resolveChromiumKeys,
17 unwrapWindowsDpapiKey,
18} from "./ChromiumKeys.ts";
19import { LinuxBrowserSecretPath } from "./LinuxBrowserSecret.ts";
20
21const { getPassword } = vi.hoisted(() => ({ getPassword: vi.fn<() => string | null>() }));
22
23vi.mock("@napi-rs/keyring", () => ({
24 Entry: class {
25 getPassword = getPassword;
26 },
27}));
28
29beforeEach(() => {
30 getPassword.mockReset();
31});
32
33type CapturedCommand = {
34 readonly command: string;
35 readonly args: ReadonlyArray<string>;
36 readonly options: {
37 readonly stdin?: string;
38 readonly env?: Readonly<Record<string, string | undefined>>;
39 };
40};
41
42const layerHelper = (input: {
43 readonly stdout?: string;
44 readonly stderr?: string;
45 readonly stdoutStream?: Stream.Stream<Uint8Array>;
46 readonly stderrStream?: Stream.Stream<Uint8Array>;
47 readonly exitCode?: number;
48 readonly spawnError?: PlatformError.PlatformError;
49 readonly capture?: (command: CapturedCommand) => void;
50}) =>
51 Layer.merge(
52 Layer.succeed(LinuxBrowserSecretPath, "/bundled/browser-secret/t3-browser-secret"),
53 Layer.succeed(
54 ChildProcessSpawner.ChildProcessSpawner,
55 ChildProcessSpawner.make((command) =>
56 input.spawnError
57 ? Effect.fail(input.spawnError)
58 : Effect.succeed(
59 ChildProcessSpawner.makeHandle({
60 pid: ChildProcessSpawner.ProcessId(1),
61 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
62 isRunning: Effect.succeed(false),
63 kill: () => Effect.void,
64 unref: Effect.succeed(Effect.void),
65 stdin: Sink.drain,
66 stdout: input.stdoutStream ?? Stream.encodeText(Stream.make(input.stdout ?? "")),
67 stderr: input.stderrStream ?? Stream.encodeText(Stream.make(input.stderr ?? "")),
68 all: Stream.empty,
69 getInputFd: () => Sink.drain,
70 getOutputFd: () => Stream.empty,
71 }),
72 ).pipe(
73 Effect.tap(() => Effect.sync(() => input.capture?.(command as CapturedCommand))),
74 ),
75 ),
76 ),
77 );
78
79describe("macOS Chromium secrets", () => {
80 const request = {
81 platform: "darwin",
82 keychainService: "Chrome Safe Storage",
83 keychainAccount: "Chrome",
84 linuxSecretApplication: undefined,
85 } as const;
86 const layerNoProcesses = Layer.succeed(
87 ChildProcessSpawner.ChildProcessSpawner,
88 ChildProcessSpawner.make(() => Effect.die("must not spawn")),
89 );
90
91 it.effect("derives the cookie key from the keychain secret", () =>
92 Effect.gen(function* () {
93 getPassword.mockReturnValue("macos-secret");
94 const keys = yield* resolveChromiumKeys(request);
95 expect(keys.cbcV10?.toString("hex")).toBe("3df7306fb1eac353289565a2f6b64f74");
96 }).pipe(Effect.provide(layerNoProcesses)),
97 );
98
99 it.effect("reports a missing keychain entry", () =>
100 Effect.gen(function* () {
101 getPassword.mockReturnValue(null);
102 const error = yield* resolveChromiumKeys(request).pipe(Effect.flip);
103 expect(error.reason).toBe("keychainItemMissing");
104 }).pipe(Effect.provide(layerNoProcesses)),
105 );
106
107 it.effect("preserves a denied keychain approval", () =>
108 Effect.gen(function* () {
109 const denied = new Error("User denied access");
110 getPassword.mockImplementation(() => {
111 throw denied;
112 });
113 const error = yield* resolveChromiumKeys(request).pipe(Effect.flip);
114 expect(error.reason).toBe("needsKeychainApproval");
115 expect(error.cause).toBe(denied);
116 }).pipe(Effect.provide(layerNoProcesses)),
117 );
118});
119
120describe("Linux Chromium secrets", () => {
121 it.effect("retains a missing helper failure alongside the keyring-free fallback", () =>
122 Effect.gen(function* () {
123 const keys = yield* resolveChromiumKeys({
124 platform: "linux",
125 keychainService: undefined,
126 keychainAccount: undefined,
127 linuxSecretApplication: "chromium",
128 });
129 expect(keys.cbcV10).toHaveLength(16);
130 expect(keys.cbcV11).toBeUndefined();
131 expect(keys.cbcV11Error?.reason).toBe("keychainUnavailable");
132 }).pipe(
133 Effect.provide(
134 layerHelper({
135 spawnError: PlatformError.systemError({
136 _tag: "NotFound",
137 module: "ChildProcess",
138 method: "spawn",
139 }),
140 }),
141 ),
142 ),
143 );
144
145 it.effect("reports an unconfigured helper without searching PATH", () =>
146 readLinuxSecret("chromium").pipe(
147 Effect.flip,
148 Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("keychainUnavailable"))),
149 Effect.provideService(
150 ChildProcessSpawner.ChildProcessSpawner,
151 ChildProcessSpawner.make(() => Effect.die("must not spawn")),
152 ),
153 Effect.provideService(LinuxBrowserSecretPath, undefined),
154 ),
155 );
156
157 it.effect("looks up the browser's libsecret application attribute", () => {
158 let captured: CapturedCommand | undefined;
159 return Effect.gen(function* () {
160 const keys = yield* resolveChromiumKeys({
161 platform: "linux",
162 keychainService: "ignored macOS service",
163 keychainAccount: "ignored macOS account",
164 linuxSecretApplication: "msedge",
165 });
166
167 expect(captured?.command).toBe("/bundled/browser-secret/t3-browser-secret");
168 expect(captured?.args).toEqual(["msedge"]);
169 expect(captured?.options.stdin).toBe("ignore");
170 expect(keys.cbcV10).toHaveLength(16);
171 expect(keys.cbcV11).toHaveLength(16);
172 }).pipe(
173 Effect.provide(
174 layerHelper({ stdout: "linux-secret", capture: (value) => (captured = value) }),
175 ),
176 );
177 });
178
179 it.effect("reports an unavailable Secret Service backend as a read failure", () =>
180 Effect.gen(function* () {
181 const error = yield* readLinuxSecret("chrome").pipe(Effect.flip);
182 expect(error).toBeInstanceOf(ChromiumKeyError);
183 expect(error.reason).toBe("keychainUnavailable");
184 }).pipe(
185 Effect.provide(
186 layerHelper({ stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY", exitCode: 1 }),
187 ),
188 ),
189 );
190
191 it.effect("preserves trailing whitespace in the stored secret", () =>
192 Effect.gen(function* () {
193 const secret = yield* readLinuxSecret("chrome");
194 expect(secret).toBe("linux-secret \t\n");
195 }).pipe(Effect.provide(layerHelper({ stdout: "linux-secret \t\n" }))),
196 );
197
198 it.effect("drains stdout and stderr concurrently", () =>
199 Effect.gen(function* () {
200 const stderrDrainStarted = yield* Deferred.make<void>();
201 const stdout = Stream.fromEffect(Deferred.await(stderrDrainStarted)).pipe(
202 Stream.flatMap(() => Stream.encodeText(Stream.make("linux-secret"))),
203 );
204 const stderr = Stream.fromEffect(Deferred.succeed(stderrDrainStarted, undefined)).pipe(
205 Stream.drain,
206 );
207
208 const secret = yield* readLinuxSecret("chrome").pipe(
209 Effect.provide(layerHelper({ stdoutStream: stdout, stderrStream: stderr })),
210 );
211
212 expect(secret).toBe("linux-secret");
213 }),
214 );
215
216 it.effect(
217 "preserves the desktop environment and identifies denial without parsing stderr",
218 () => {
219 let captured: CapturedCommand | undefined;
220 return Effect.gen(function* () {
221 const error = yield* readLinuxSecret("brave").pipe(Effect.flip);
222 expect(error).toBeInstanceOf(ChromiumKeyError);
223 expect(error.reason).toBe("needsKeychainApproval");
224 expect(captured?.options.env?.LC_ALL).toBe("localized");
225 expect(captured?.options.env?.PATH).toBe("/synthetic/bin");
226 expect(captured?.options.env?.SESSION_MARKER).toBe("kept");
227 }).pipe(
228 Effect.provide(
229 layerHelper({
230 stderr: "Zugriff verweigert",
231 exitCode: 3,
232 capture: (value) => (captured = value),
233 }),
234 ),
235 Effect.provideService(HostProcess.Environment, {
236 PATH: "/synthetic/bin",
237 SESSION_MARKER: "kept",
238 LC_ALL: "localized",
239 }),
240 );
241 },
242 );
243
244 it.effect("does not discard a denied unlock prompt while resolving keys", () =>
245 Effect.gen(function* () {
246 const error = yield* resolveChromiumKeys({
247 platform: "linux",
248 keychainService: undefined,
249 keychainAccount: undefined,
250 linuxSecretApplication: "brave",
251 }).pipe(Effect.flip);
252 expect(error.reason).toBe("needsKeychainApproval");
253 }).pipe(Effect.provide(layerHelper({ stderr: "Keyring is locked", exitCode: 3 }))),
254 );
255
256 it.effect("keeps the v10 fallback when the Secret Service backend is unavailable", () =>
257 Effect.gen(function* () {
258 const keys = yield* resolveChromiumKeys({
259 platform: "linux",
260 keychainService: undefined,
261 keychainAccount: undefined,
262 linuxSecretApplication: "chrome",
263 });
264 expect(keys.cbcV10).toHaveLength(16);
265 expect(keys.cbcV11).toBeUndefined();
266 }).pipe(
267 Effect.provide(
268 layerHelper({
269 stderr: "Cannot autolaunch D-Bus without X11 $DISPLAY",
270 exitCode: 1,
271 }),
272 ),
273 ),
274 );
275
276 it.effect("keeps the v10 fallback when no matching v11 secret exists", () =>
277 Effect.gen(function* () {
278 const keys = yield* resolveChromiumKeys({
279 platform: "linux",
280 keychainService: undefined,
281 keychainAccount: undefined,
282 linuxSecretApplication: "vivaldi",
283 });
284 expect(keys.cbcV10).toHaveLength(16);
285 expect(keys.cbcV11).toBeUndefined();
286 }).pipe(Effect.provide(layerHelper({ exitCode: 2 }))),
287 );
288});
289
290describe("Windows Chromium secrets", () => {
291 it.effect("accepts only DPAPI-wrapped non-app-bound keys", () =>
292 Effect.gen(function* () {
293 const wrapped = Buffer.from("wrapped-key");
294 const encoded = Buffer.concat([Buffer.from("DPAPI"), wrapped]).toString("base64");
295 const localState = `{"os_crypt":{"encrypted_key":"${encoded}"}}`;
296 expect(yield* decodeWindowsWrappedKey(localState)).toEqual(wrapped);
297
298 const appBound = yield* decodeWindowsWrappedKey(
299 `{"os_crypt":{"encrypted_key":"${encoded}","app_bound_encrypted_key":"present"}}`,
300 ).pipe(Effect.flip);
301 expect(appBound.reason).toBe("unsupportedPlatform");
302
303 const malformed = yield* decodeWindowsWrappedKey(
304 `{"os_crypt":{"encrypted_key":"${wrapped.toString("base64")}"}}`,
305 ).pipe(Effect.flip);
306 expect(malformed.reason).toBe("readFailed");
307 }),
308 );
309
310 it.effect("unwraps the binary key through PowerShell without placing it in argv", () => {
311 let captured: CapturedCommand | undefined;
312 const wrapped = Buffer.from("wrapped-key");
313 const key = Buffer.from("0123456789abcdef0123456789abcdef");
314 return Effect.gen(function* () {
315 expect(yield* unwrapWindowsDpapiKey(wrapped)).toEqual(key);
316 expect(captured?.command).toBe(
317 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
318 );
319 expect(captured?.args).toContain("-NonInteractive");
320 expect(captured?.args.join(" ")).not.toContain(wrapped.toString("base64"));
321 }).pipe(
322 Effect.provide(
323 layerHelper({ stdout: key.toString("base64"), capture: (value) => (captured = value) }),
324 ),
325 Effect.provideService(HostProcess.Environment, { SystemRoot: "C:\\Windows" }),
326 );
327 });
328});