1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Fiber from "effect/Fiber";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import { afterEach, beforeEach, vi } from "vite-plus/test";
7
8const electron = vi.hoisted(() => ({
9 configureWebAuthn: vi.fn(),
10 showMessageBox: vi.fn(),
11 fromWebContents: vi.fn(),
12}));
13const webauthn = vi.hoisted(() => ({ createCredential: vi.fn(), getCredential: vi.fn() }));
14
15vi.mock("electron", () => ({
16 app: { configureWebAuthn: electron.configureWebAuthn },
17 dialog: { showMessageBox: electron.showMessageBox },
18 BrowserWindow: { fromWebContents: electron.fromWebContents },
19 webContents: { fromFrame: () => undefined },
20}));
21vi.mock("electron-webauthn", () => webauthn);
22
23import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
24import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts";
25import * as PreviewPasskeys from "./Passkeys.ts";
26
27const layerFor = (packageJson: object, isPackaged = true) =>
28 PreviewPasskeys.layer.pipe(
29 Layer.provide(
30 Layer.succeed(
31 DesktopEnvironment.DesktopEnvironment,
32 DesktopEnvironment.DesktopEnvironment.of({
33 platform: "darwin",
34 isPackaged,
35 appRoot: "/app",
36 path: { join: (...parts: ReadonlyArray<string>) => parts.join("/") },
37 } as DesktopEnvironment.DesktopEnvironment["Service"]),
38 ),
39 ),
40 Layer.provide(
41 FileSystem.layerNoop({
42 readFileString: () => Effect.succeed(JSON.stringify(packageJson)),
43 }),
44 ),
45 );
46
47type Handler = (event: { readonly senderFrame: object | null }, publicKey: unknown) => unknown;
48
49const makeGuest = (origin = "https://accounts.example.com") => {
50 const handlers = new Map<string, Handler>();
51 const state = { focused: true };
52 const mainFrame = { origin, isDestroyed: () => false };
53 const guest = {
54 mainFrame,
55 hostWebContents: {},
56 isFocused: () => state.focused,
57 ipc: {
58 handle: (channel: string, handler: Handler) => handlers.set(channel, handler),
59 removeHandler: (channel: string) => handlers.delete(channel),
60 },
61 };
62 const call = (channel: string, publicKey: unknown, senderFrame: object | null = mainFrame) =>
63 Effect.promise(async () => handlers.get(channel)?.({ senderFrame }, publicKey));
64 return { guest: guest as unknown as Electron.WebContents, mainFrame, handlers, state, call };
65};
66
67const bridgeLayer = layerFor({ t3codeWebAuthn: { browserPasskeys: true } });
68
69/** CBOR `{ fmt: "packed", attStmt: { alg: -7, sig }, authData }`, as authenticators encode it. */
70const attestationObject = (authData: ReadonlyArray<number>) => {
71 const text = (value: string) => [0x60 + value.length, ...Buffer.from(value)];
72 const bytes = (value: ReadonlyArray<number>) => [0x58, value.length, ...value];
73 return Buffer.from([
74 0xa3,
75 ...text("fmt"),
76 ...text("packed"),
77 ...text("attStmt"),
78 0xa2,
79 ...text("alg"),
80 0x26,
81 ...text("sig"),
82 ...bytes(Array.from({ length: 70 }, () => 7)),
83 ...text("authData"),
84 ...bytes(authData),
85 ]).toString("base64url");
86};
87
88describe("PreviewPasskeys", () => {
89 beforeEach(() => {
90 vi.clearAllMocks();
91 electron.fromWebContents.mockReturnValue({
92 isDestroyed: () => false,
93 getNativeWindowHandle: () => Buffer.from([1]),
94 });
95 });
96 // Restored here so a failing fake-timer test cannot leak fake timers into the next one.
97 afterEach(() => {
98 vi.useRealTimers();
99 });
100
101 it.effect("pins each guest ceremony to the origin of the frame that asked", () =>
102 Effect.gen(function* () {
103 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
104 const { guest, handlers, call } = makeGuest();
105 webauthn.getCredential.mockResolvedValue({
106 success: false,
107 error: "NotAllowedError",
108 errorObject: new Error("native detail"),
109 });
110
111 const detach = passkeys.attachGuest(guest);
112 const publicKey = { challenge: new Uint8Array([1]), rpId: "example.com" };
113 const result = yield* call(PASSKEY_GET_CHANNEL, publicKey);
114
115 assert.deepStrictEqual(result, { success: false, error: "NotAllowedError" });
116 const [sentPublicKey, options] = webauthn.getCredential.mock.calls[0] ?? [];
117 assert.deepStrictEqual(sentPublicKey, publicKey);
118 assert.strictEqual(options.currentOrigin, "https://accounts.example.com");
119 assert.strictEqual(options.topFrameOrigin, "https://accounts.example.com");
120 assert.isTrue(options.isPublicSuffix("com"));
121 assert.isTrue(options.isPublicSuffix("github.io"));
122 assert.isFalse(options.isPublicSuffix("example.com"));
123 assert.isFalse(options.isPublicSuffix("localhost"));
124
125 // WebAuthn defaults the RP ID to the caller's own host.
126 yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) });
127 assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com");
128
129 // Only the main frame runs the preload; anything else is not the page.
130 const forged = yield* call(PASSKEY_CREATE_CHANNEL, publicKey, {
131 origin: "https://evil.example",
132 });
133 assert.deepStrictEqual(forged, { success: false, error: "NotAllowedError" });
134 assert.strictEqual(webauthn.createCredential.mock.calls.length, 0);
135
136 detach();
137 assert.strictEqual(handlers.size, 0);
138 }).pipe(Effect.provide(bridgeLayer)),
139 );
140
141 it.effect("registers ES256 keys only and reports their real authenticator data", () =>
142 Effect.gen(function* () {
143 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
144 const { guest, call } = makeGuest();
145 passkeys.attachGuest(guest);
146 webauthn.createCredential.mockResolvedValue({
147 success: true,
148 data: {
149 credentialId: "AQ",
150 clientDataJSON: "Ag",
151 attestationObject: attestationObject([1, 2, 3, 4]),
152 authData: Buffer.from('{"parsed":true}').toString("base64url"),
153 publicKey: "BQ",
154 publicKeyAlgorithm: -7,
155 transports: ["hybrid", "internal"],
156 extensions: {},
157 },
158 });
159 const publicKey = {
160 challenge: new Uint8Array([1]),
161 pubKeyCredParams: [
162 { type: "public-key", alg: -8 },
163 { type: "public-key", alg: -7 },
164 { type: "public-key", alg: -257 },
165 ],
166 };
167
168 const result = yield* call(PASSKEY_CREATE_CHANNEL, publicKey);
169
170 assert.deepStrictEqual(webauthn.createCredential.mock.calls[0]?.[0].pubKeyCredParams, [
171 { type: "public-key", alg: -7 },
172 ]);
173 assert.deepInclude(result, { success: true });
174 const data = (result as { readonly data: Record<string, unknown> }).data;
175 assert.strictEqual(data.authData, Buffer.from([1, 2, 3, 4]).toString("base64url"));
176 assert.deepStrictEqual(data.transports, []);
177
178 const unsupported = yield* call(PASSKEY_CREATE_CHANNEL, {
179 ...publicKey,
180 pubKeyCredParams: [{ type: "public-key", alg: -8 }],
181 });
182 assert.deepStrictEqual(unsupported, { success: false, error: "NotSupportedError" });
183 assert.strictEqual(webauthn.createCredential.mock.calls.length, 1);
184
185 // A malformed algorithm list is the page's error, not a cue to pick ES256.
186 const malformed = yield* call(PASSKEY_CREATE_CHANNEL, {
187 ...publicKey,
188 pubKeyCredParams: { type: "public-key", alg: -7 },
189 });
190 assert.deepStrictEqual(malformed, { success: false, error: "TypeError" });
191 assert.strictEqual(webauthn.createCredential.mock.calls.length, 1);
192
193 // Without authenticator data the site cannot verify the passkey.
194 webauthn.createCredential.mockResolvedValueOnce({
195 success: true,
196 data: { credentialId: "AQ", attestationObject: Buffer.from([0x00]).toString("base64url") },
197 });
198 assert.deepStrictEqual(yield* call(PASSKEY_CREATE_CHANNEL, publicKey), {
199 success: false,
200 error: "NotAllowedError",
201 });
202 }).pipe(Effect.provide(bridgeLayer)),
203 );
204
205 it.effect("answers only with a credential the site allowed", () =>
206 Effect.gen(function* () {
207 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
208 const { guest, call } = makeGuest();
209 passkeys.attachGuest(guest);
210 const assertion = (credentialId: string) => ({
211 success: true,
212 data: {
213 credentialId,
214 clientDataJSON: "Ag",
215 authenticatorData: "Aw",
216 signature: "BA",
217 userHandle: "BQ",
218 },
219 });
220 const publicKey = {
221 challenge: new Uint8Array([1]),
222 rpId: "example.com",
223 allowCredentials: [{ type: "public-key", id: new Uint8Array([1]) }],
224 };
225
226 // macOS applies the allow list to passkeys but not to security keys.
227 webauthn.getCredential.mockResolvedValueOnce(assertion("Ag"));
228 const other = yield* call(PASSKEY_GET_CHANNEL, publicKey);
229 assert.deepStrictEqual(other, { success: false, error: "NotAllowedError" });
230
231 webauthn.getCredential.mockResolvedValueOnce(assertion("AQ"));
232 assert.deepInclude(yield* call(PASSKEY_GET_CHANNEL, publicKey), { success: true });
233
234 // With no allow list, any of the site's credentials will do.
235 webauthn.getCredential.mockResolvedValueOnce(assertion("Ag"));
236 const discoverable = yield* call(PASSKEY_GET_CHANNEL, {
237 challenge: new Uint8Array([1]),
238 rpId: "example.com",
239 });
240 assert.deepInclude(discoverable, { success: true });
241 }).pipe(Effect.provide(bridgeLayer)),
242 );
243
244 it.effect("opens the sheet only for a focused, secure page with nothing else pending", () =>
245 Effect.gen(function* () {
246 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
247 const notAllowed = { success: false, error: "NotAllowedError" };
248 const publicKey = { challenge: new Uint8Array([1]), rpId: "example.com" };
249
250 const lan = makeGuest("http://192.168.1.5:3000");
251 passkeys.attachGuest(lan.guest);
252 assert.deepStrictEqual(yield* lan.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed);
253 // macOS refuses `*.localhost`, so the page hears no rather than a native error.
254 const subdomain = makeGuest("http://app.localhost:3000");
255 passkeys.attachGuest(subdomain.guest);
256 assert.deepStrictEqual(yield* subdomain.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed);
257
258 const page = makeGuest();
259 passkeys.attachGuest(page.guest);
260 page.state.focused = false;
261 assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed);
262 assert.strictEqual(webauthn.getCredential.mock.calls.length, 0);
263
264 page.state.focused = true;
265 let finish: (value: unknown) => void = () => {};
266 webauthn.getCredential.mockReturnValueOnce(new Promise((resolve) => (finish = resolve)));
267 const first = yield* Effect.forkChild(page.call(PASSKEY_GET_CHANNEL, publicKey));
268 yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve)));
269 assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed);
270
271 // The page navigated while the sheet was up; its credential goes nowhere.
272 page.mainFrame.origin = "https://other.example";
273 finish({ success: true, data: { credentialId: "AQ" } });
274 assert.deepStrictEqual(yield* Fiber.join(first), notAllowed);
275 }).pipe(Effect.provide(bridgeLayer)),
276 );
277
278 it.effect("answers the page even when the native ceremony never settles", () =>
279 Effect.gen(function* () {
280 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
281 const { guest, call } = makeGuest();
282 passkeys.attachGuest(guest);
283 webauthn.getCredential.mockReturnValueOnce(new Promise(() => {}));
284 vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
285
286 const pending = yield* Effect.forkChild(
287 call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]), timeout: 1_000 }),
288 );
289 yield* Effect.promise(() => vi.advanceTimersByTimeAsync(6_000));
290
291 assert.deepStrictEqual(yield* Fiber.join(pending), {
292 success: false,
293 error: "NotAllowedError",
294 });
295 }).pipe(Effect.provide(bridgeLayer)),
296 );
297
298 it.effect("turns each path on only for builds signed for it", () =>
299 Effect.gen(function* () {
300 const entitled = yield* PreviewPasskeys.PreviewPasskeys.pipe(
301 Effect.provide(
302 layerFor({
303 t3codeWebAuthn: {
304 touchIdKeychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn",
305 browserPasskeys: false,
306 },
307 }),
308 ),
309 );
310 yield* entitled.configure;
311 assert.deepStrictEqual(electron.configureWebAuthn.mock.calls, [
312 [{ touchID: { keychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn" } }],
313 ]);
314 assert.isFalse(entitled.bridgeEnabled);
315 const { guest, handlers } = makeGuest();
316 entitled.attachGuest(guest);
317 assert.strictEqual(handlers.size, 0);
318
319 const unpackaged = yield* PreviewPasskeys.PreviewPasskeys.pipe(
320 Effect.provide(
321 layerFor(
322 { t3codeWebAuthn: { touchIdKeychainAccessGroup: "X", browserPasskeys: true } },
323 false,
324 ),
325 ),
326 );
327 yield* unpackaged.configure;
328 assert.strictEqual(electron.configureWebAuthn.mock.calls.length, 1);
329 assert.isFalse(unpackaged.bridgeEnabled);
330 }),
331 );
332
333 it.effect("asks which passkey to use only when a site has several", () =>
334 Effect.gen(function* () {
335 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
336 const listeners: Array<(...args: ReadonlyArray<unknown>) => void> = [];
337 const session = {
338 on: (_event: string, listener: (...args: ReadonlyArray<unknown>) => void) =>
339 listeners.push(listener),
340 } as unknown as Electron.Session;
341 passkeys.installSessionHandlers(session);
342 passkeys.installSessionHandlers(session);
343 assert.strictEqual(listeners.length, 1);
344
345 const select = (accounts: ReadonlyArray<Electron.WebAuthnAccount>) =>
346 Effect.promise(
347 () =>
348 new Promise<unknown>((resolve) =>
349 listeners[0]?.(
350 {},
351 { relyingPartyId: "example.com", accounts, frame: null },
352 (credentialId?: string) => resolve(credentialId),
353 ),
354 ),
355 );
356 const alice = { credentialId: "alice", name: "alice@example.com" };
357 const bob = { credentialId: "bob", displayName: "Bob", name: "bob@example.com" };
358
359 assert.strictEqual(yield* select([alice]), "alice");
360 assert.strictEqual(electron.showMessageBox.mock.calls.length, 0);
361
362 electron.showMessageBox.mockResolvedValueOnce({ response: 1 });
363 assert.strictEqual(yield* select([alice, bob]), "bob");
364 assert.deepStrictEqual(electron.showMessageBox.mock.calls[0]?.[0]?.buttons, [
365 "alice@example.com",
366 "Bob (bob@example.com)",
367 "Cancel",
368 ]);
369
370 electron.showMessageBox.mockResolvedValueOnce({ response: 2 });
371 assert.isUndefined(yield* select([alice, bob]));
372 }).pipe(Effect.provide(layerFor({}))),
373 );
374});