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

ChromiumKeys.test.ts

267 lines9.5 KB

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";
20import { BROWSER_IMPORT_SOURCES } from "./Sources.ts";
21​
22const { getPassword } = vi.hoisted(() => ({
23 getPassword: vi.fn<() => Promise<string | undefined>>(),
24}));
25​
26vi.mock("@napi-rs/keyring", () => ({
27 Entry: class {
28 getPassword() {
29 throw new Error("synchronous keychain read");
30 }
31 },
32 AsyncEntry: class {
33 getPassword = getPassword;
34 },
35}));
36​
37beforeEach(() => {
38 getPassword.mockReset();
39});
40​
41type CapturedCommand = {
42 readonly command: string;
43 readonly args: ReadonlyArray<string>;
44 readonly options: {
45 readonly stdin?: string;
46 readonly env?: Readonly<Record<string, string | undefined>>;
47 };
48};
49​
50const layerHelper = (input: {
51 readonly stdout?: string;
52 readonly stderr?: string;
53 readonly stdoutStream?: Stream.Stream<Uint8Array>;
54 readonly stderrStream?: Stream.Stream<Uint8Array>;
55 readonly exitCode?: number;
56 readonly spawnError?: PlatformError.PlatformError;
57 readonly capture?: (command: CapturedCommand) => void;
58}) =>
59 Layer.merge(
60 Layer.succeed(LinuxBrowserSecretPath, "/bundled/browser-secret/t3-browser-secret"),
61 Layer.succeed(
62 ChildProcessSpawner.ChildProcessSpawner,
63 ChildProcessSpawner.make((command) =>
64 input.spawnError
65 ? Effect.fail(input.spawnError)
66 : Effect.succeed(
67 ChildProcessSpawner.makeHandle({
68 pid: ChildProcessSpawner.ProcessId(1),
69 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(input.exitCode ?? 0)),
70 isRunning: Effect.succeed(false),
71 kill: () => Effect.void,
72 unref: Effect.succeed(Effect.void),
73 stdin: Sink.drain,
74 stdout: input.stdoutStream ?? Stream.encodeText(Stream.make(input.stdout ?? "")),
75 stderr: input.stderrStream ?? Stream.encodeText(Stream.make(input.stderr ?? "")),
76 all: Stream.empty,
77 getInputFd: () => Sink.drain,
78 getOutputFd: () => Stream.empty,
79 }),
80 ).pipe(
81 Effect.tap(() => Effect.sync(() => input.capture?.(command as CapturedCommand))),
82 ),
83 ),
84 ),
85 );
86​
87describe("macOS Chromium secrets", () => {
88 const request = {
89 platform: "darwin",
90 keychainService: "Chrome Safe Storage",
91 keychainAccount: "Chrome",
92 linuxSecretApplication: undefined,
93 } as const;
94 const layerNoProcesses = Layer.succeed(
95 ChildProcessSpawner.ChildProcessSpawner,
96 ChildProcessSpawner.make(() => Effect.die("must not spawn")),
97 );
98​
99 it.effect("derives the cookie key from the keychain secret", () =>
100 Effect.gen(function* () {
101 getPassword.mockResolvedValue("macos-secret");
102 const keys = yield* resolveChromiumKeys(request);
103 expect(keys.cbcV10?.toString("hex")).toBe("3df7306fb1eac353289565a2f6b64f74");
104 }).pipe(Effect.provide(layerNoProcesses)),
105 );
106​
107 it.effect("reports a keychain entry the async read resolves as undefined", () =>
108 Effect.gen(function* () {
109 getPassword.mockResolvedValue(undefined);
110 const error = yield* resolveChromiumKeys(request).pipe(Effect.flip);
111 expect(error.reason).toBe("keychainItemMissing");
112 }).pipe(Effect.provide(layerNoProcesses)),
113 );
114​
115 it.effect("preserves a denied keychain approval", () =>
116 Effect.gen(function* () {
117 const denied = new Error("User denied access");
118 getPassword.mockRejectedValue(denied);
119 const error = yield* resolveChromiumKeys(request).pipe(Effect.flip);
120 expect(error.reason).toBe("needsKeychainApproval");
121 expect(error.cause).toBe(denied);
122 }).pipe(Effect.provide(layerNoProcesses)),
123 );
124});
125​
126describe("Linux Chromium secrets", () => {
127 it.effect("retains a missing helper failure alongside the keyring-free fallback", () =>
128 Effect.gen(function* () {
129 const keys = yield* resolveChromiumKeys({
130 platform: "linux",
131 keychainService: undefined,
132 keychainAccount: undefined,
133 linuxSecretApplication: "chromium",
134 });
135 expect(keys.cbcV10).toHaveLength(16);
136 expect(keys.cbcV11).toBeUndefined();
137 expect(keys.cbcV11Error?.reason).toBe("keychainUnavailable");
138 }).pipe(
139 Effect.provide(
140 layerHelper({
141 spawnError: PlatformError.systemError({
142 _tag: "NotFound",
143 module: "ChildProcess",
144 method: "spawn",
145 }),
146 }),
147 ),
148 ),
149 );
150​
151 it.effect("reports an unconfigured helper without searching PATH", () =>
152 readLinuxSecret("chromium").pipe(
153 Effect.flip,
154 Effect.tap((error) => Effect.sync(() => expect(error.reason).toBe("keychainUnavailable"))),
155 Effect.provideService(
156 ChildProcessSpawner.ChildProcessSpawner,
157 ChildProcessSpawner.make(() => Effect.die("must not spawn")),
158 ),
159 Effect.provideService(LinuxBrowserSecretPath, undefined),
160 ),
161 );
162​
163 it.effect("derives Vivaldi's Linux key while preserving whitespace in the stored secret", () => {
164 let captured: CapturedCommand | undefined;
165 return Effect.gen(function* () {
166 const source = BROWSER_IMPORT_SOURCES.find((source) => source.id === "vivaldi")!;
167 const keys = yield* resolveChromiumKeys({
168 platform: "linux",
169 keychainService: source.keychainService,
170 keychainAccount: source.keychainAccount,
171 linuxSecretApplication: source.linuxSecretApplication,
172 });
173 expect(captured?.args).toEqual(["chrome"]);
174 expect(keys.cbcV11?.toString("hex")).toBe("95aa769d6a5beca14d5d90ae655db112");
175 }).pipe(
176 Effect.provide(
177 layerHelper({ stdout: "linux-secret \t\n", capture: (value) => (captured = value) }),
178 ),
179 );
180 });
181​
182 it.effect("drains stdout and stderr concurrently", () =>
183 Effect.gen(function* () {
184 const stderrDrainStarted = yield* Deferred.make<void>();
185 const stdout = Stream.fromEffect(Deferred.await(stderrDrainStarted)).pipe(
186 Stream.flatMap(() => Stream.encodeText(Stream.make("linux-secret"))),
187 );
188 const stderr = Stream.fromEffect(Deferred.succeed(stderrDrainStarted, undefined)).pipe(
189 Stream.drain,
190 );
191​
192 const secret = yield* readLinuxSecret("chrome").pipe(
193 Effect.provide(layerHelper({ stdoutStream: stdout, stderrStream: stderr })),
194 );
195​
196 expect(secret).toBe("linux-secret");
197 }),
198 );
199​
200 it.effect(
201 "preserves the desktop environment and identifies denial without parsing stderr",
202 () => {
203 let captured: CapturedCommand | undefined;
204 return Effect.gen(function* () {
205 const error = yield* readLinuxSecret("brave").pipe(Effect.flip);
206 expect(error).toBeInstanceOf(ChromiumKeyError);
207 expect(error.reason).toBe("needsKeychainApproval");
208 expect(captured?.options.env?.LC_ALL).toBe("localized");
209 expect(captured?.options.env?.PATH).toBe("/synthetic/bin");
210 expect(captured?.options.env?.SESSION_MARKER).toBe("kept");
211 }).pipe(
212 Effect.provide(
213 layerHelper({
214 stderr: "Zugriff verweigert",
215 exitCode: 3,
216 capture: (value) => (captured = value),
217 }),
218 ),
219 Effect.provideService(HostProcess.Environment, {
220 PATH: "/synthetic/bin",
221 SESSION_MARKER: "kept",
222 LC_ALL: "localized",
223 }),
224 );
225 },
226 );
227});
228​
229describe("Windows Chromium secrets", () => {
230 it.effect("accepts only DPAPI-wrapped non-app-bound keys", () =>
231 Effect.gen(function* () {
232 const wrapped = Buffer.from("wrapped-key");
233 const encoded = Buffer.concat([Buffer.from("DPAPI"), wrapped]).toString("base64");
234 const localState = `{"os_crypt":{"encrypted_key":"${encoded}"}}`;
235 expect(yield* decodeWindowsWrappedKey(localState)).toEqual(wrapped);
236​
237 const appBound = yield* decodeWindowsWrappedKey(
238 `{"os_crypt":{"encrypted_key":"${encoded}","app_bound_encrypted_key":"present"}}`,
239 ).pipe(Effect.flip);
240 expect(appBound.reason).toBe("unsupportedPlatform");
241​
242 const malformed = yield* decodeWindowsWrappedKey(
243 `{"os_crypt":{"encrypted_key":"${wrapped.toString("base64")}"}}`,
244 ).pipe(Effect.flip);
245 expect(malformed.reason).toBe("readFailed");
246 }),
247 );
248​
249 it.effect("unwraps the binary key through PowerShell without placing it in argv", () => {
250 let captured: CapturedCommand | undefined;
251 const wrapped = Buffer.from("wrapped-key");
252 const key = Buffer.from("0123456789abcdef0123456789abcdef");
253 return Effect.gen(function* () {
254 expect(yield* unwrapWindowsDpapiKey(wrapped)).toEqual(key);
255 expect(captured?.command).toBe(
256 "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
257 );
258 expect(captured?.args).toContain("-NonInteractive");
259 expect(captured?.args.join(" ")).not.toContain(wrapped.toString("base64"));
260 }).pipe(
261 Effect.provide(
262 layerHelper({ stdout: key.toString("base64"), capture: (value) => (captured = value) }),
263 ),
264 Effect.provideService(HostProcess.Environment, { SystemRoot: "C:\\Windows" }),
265 );
266 });
267});