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
69const attestationObject = (authData: ReadonlyArray<number>) => {
70 const text = (value: string) => [0x60 + value.length, ...Buffer.from(value)];
71 const bytes = (value: ReadonlyArray<number>) => [0x58, value.length, ...value];
72 return Buffer.from([
73 0xa3,
74 ...text("fmt"),
75 ...text("packed"),
76 ...text("attStmt"),
77 0xa2,
78 ...text("alg"),
79 0x26,
80 ...text("sig"),
81 ...bytes(Array.from({ length: 70 }, () => 7)),
82 ...text("authData"),
83 ...bytes(authData),
84 ]).toString("base64url");
85};
86
87describe("PreviewPasskeys", () => {
88 beforeEach(() => {
89 vi.clearAllMocks();
90 electron.fromWebContents.mockReturnValue({
91 isDestroyed: () => false,
92 getNativeWindowHandle: () => Buffer.from([1]),
93 });
94 });
95 afterEach(() => {
96 vi.useRealTimers();
97 });
98
99 it.effect("pins each guest ceremony to the origin of the frame that asked", () =>
100 Effect.gen(function* () {
101 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
102 const { guest, handlers, call } = makeGuest();
103 webauthn.getCredential.mockResolvedValue({
104 success: false,
105 error: "NotAllowedError",
106 errorObject: new Error("native detail"),
107 });
108
109 const detach = passkeys.attachGuest(guest);
110 const publicKey = { challenge: new Uint8Array([1]), rpId: "example.com" };
111 const result = yield* call(PASSKEY_GET_CHANNEL, publicKey);
112
113 assert.deepStrictEqual(result, { success: false, error: "NotAllowedError" });
114 const [sentPublicKey, options] = webauthn.getCredential.mock.calls[0] ?? [];
115 assert.deepStrictEqual(sentPublicKey, publicKey);
116 assert.strictEqual(options.currentOrigin, "https://accounts.example.com");
117 assert.strictEqual(options.topFrameOrigin, "https://accounts.example.com");
118 assert.isTrue(options.isPublicSuffix("com"));
119 assert.isTrue(options.isPublicSuffix("github.io"));
120 assert.isFalse(options.isPublicSuffix("example.com"));
121 assert.isFalse(options.isPublicSuffix("localhost"));
122
123 yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) });
124 assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com");
125
126 const forged = yield* call(PASSKEY_CREATE_CHANNEL, publicKey, {
127 origin: "https://evil.example",
128 });
129 assert.deepStrictEqual(forged, { success: false, error: "NotAllowedError" });
130 assert.strictEqual(webauthn.createCredential.mock.calls.length, 0);
131
132 detach();
133 assert.strictEqual(handlers.size, 0);
134 }).pipe(Effect.provide(bridgeLayer)),
135 );
136
137 it.effect("registers ES256 keys only and reports their real authenticator data", () =>
138 Effect.gen(function* () {
139 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
140 const { guest, call } = makeGuest();
141 passkeys.attachGuest(guest);
142 webauthn.createCredential.mockResolvedValue({
143 success: true,
144 data: {
145 credentialId: "AQ",
146 clientDataJSON: "Ag",
147 attestationObject: attestationObject([1, 2, 3, 4]),
148 authData: Buffer.from('{"parsed":true}').toString("base64url"),
149 publicKey: "BQ",
150 publicKeyAlgorithm: -7,
151 transports: ["hybrid", "internal"],
152 extensions: {},
153 },
154 });
155 const publicKey = {
156 challenge: new Uint8Array([1]),
157 pubKeyCredParams: [
158 { type: "public-key", alg: -8 },
159 { type: "public-key", alg: -7 },
160 { type: "public-key", alg: -257 },
161 ],
162 };
163
164 const result = yield* call(PASSKEY_CREATE_CHANNEL, publicKey);
165
166 assert.deepStrictEqual(webauthn.createCredential.mock.calls[0]?.[0].pubKeyCredParams, [
167 { type: "public-key", alg: -7 },
168 ]);
169 assert.deepInclude(result, { success: true });
170 const data = (result as { readonly data: Record<string, unknown> }).data;
171 assert.strictEqual(data.authData, Buffer.from([1, 2, 3, 4]).toString("base64url"));
172 assert.deepStrictEqual(data.transports, []);
173
174 const unsupported = yield* call(PASSKEY_CREATE_CHANNEL, {
175 ...publicKey,
176 pubKeyCredParams: [{ type: "public-key", alg: -8 }],
177 });
178 assert.deepStrictEqual(unsupported, { success: false, error: "NotSupportedError" });
179 assert.strictEqual(webauthn.createCredential.mock.calls.length, 1);
180
181 const malformed = yield* call(PASSKEY_CREATE_CHANNEL, {
182 ...publicKey,
183 pubKeyCredParams: { type: "public-key", alg: -7 },
184 });
185 assert.deepStrictEqual(malformed, { success: false, error: "TypeError" });
186 assert.strictEqual(webauthn.createCredential.mock.calls.length, 1);
187
188 webauthn.createCredential.mockResolvedValueOnce({
189 success: true,
190 data: { credentialId: "AQ", attestationObject: Buffer.from([0x00]).toString("base64url") },
191 });
192 assert.deepStrictEqual(yield* call(PASSKEY_CREATE_CHANNEL, publicKey), {
193 success: false,
194 error: "NotAllowedError",
195 });
196 }).pipe(Effect.provide(bridgeLayer)),
197 );
198
199 it.effect("answers only with a credential the site allowed", () =>
200 Effect.gen(function* () {
201 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
202 const { guest, call } = makeGuest();
203 passkeys.attachGuest(guest);
204 const assertion = (credentialId: string) => ({
205 success: true,
206 data: {
207 credentialId,
208 clientDataJSON: "Ag",
209 authenticatorData: "Aw",
210 signature: "BA",
211 userHandle: "BQ",
212 },
213 });
214 const publicKey = {
215 challenge: new Uint8Array([1]),
216 rpId: "example.com",
217 allowCredentials: [{ type: "public-key", id: new Uint8Array([1]) }],
218 };
219
220 webauthn.getCredential.mockResolvedValueOnce(assertion("Ag"));
221 const other = yield* call(PASSKEY_GET_CHANNEL, publicKey);
222 assert.deepStrictEqual(other, { success: false, error: "NotAllowedError" });
223
224 webauthn.getCredential.mockResolvedValueOnce(assertion("AQ"));
225 assert.deepInclude(yield* call(PASSKEY_GET_CHANNEL, publicKey), { success: true });
226
227 webauthn.getCredential.mockResolvedValueOnce(assertion("Ag"));
228 const discoverable = yield* call(PASSKEY_GET_CHANNEL, {
229 challenge: new Uint8Array([1]),
230 rpId: "example.com",
231 });
232 assert.deepInclude(discoverable, { success: true });
233 }).pipe(Effect.provide(bridgeLayer)),
234 );
235
236 it.effect("opens the sheet only for a focused, secure page with nothing else pending", () =>
237 Effect.gen(function* () {
238 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
239 const notAllowed = { success: false, error: "NotAllowedError" };
240 const publicKey = { challenge: new Uint8Array([1]), rpId: "example.com" };
241
242 const lan = makeGuest("http://192.168.1.5:3000");
243 passkeys.attachGuest(lan.guest);
244 assert.deepStrictEqual(yield* lan.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed);
245 const subdomain = makeGuest("http://app.localhost:3000");
246 passkeys.attachGuest(subdomain.guest);
247 assert.deepStrictEqual(yield* subdomain.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed);
248
249 const page = makeGuest();
250 passkeys.attachGuest(page.guest);
251 page.state.focused = false;
252 assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed);
253 assert.strictEqual(webauthn.getCredential.mock.calls.length, 0);
254
255 page.state.focused = true;
256 let finish: (value: unknown) => void = () => {};
257 const ceremonyStarted = Promise.withResolvers<void>();
258 webauthn.getCredential.mockImplementationOnce(() => {
259 ceremonyStarted.resolve();
260 return new Promise((resolve) => (finish = resolve));
261 });
262 const first = yield* Effect.forkChild(page.call(PASSKEY_GET_CHANNEL, publicKey));
263 yield* Effect.promise(() => ceremonyStarted.promise);
264 assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed);
265
266 page.mainFrame.origin = "https://other.example";
267 finish({ success: true, data: { credentialId: "AQ" } });
268 assert.deepStrictEqual(yield* Fiber.join(first), notAllowed);
269 }).pipe(Effect.provide(bridgeLayer)),
270 );
271
272 it.effect("turns each path on only for builds signed for it", () =>
273 Effect.gen(function* () {
274 const entitled = yield* PreviewPasskeys.PreviewPasskeys.pipe(
275 Effect.provide(
276 layerFor({
277 t3codeWebAuthn: {
278 touchIdKeychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn",
279 browserPasskeys: false,
280 },
281 }),
282 ),
283 );
284 yield* entitled.configure;
285 assert.deepStrictEqual(electron.configureWebAuthn.mock.calls, [
286 [{ touchID: { keychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn" } }],
287 ]);
288 assert.isFalse(entitled.bridgeEnabled);
289 const { guest, handlers } = makeGuest();
290 entitled.attachGuest(guest);
291 assert.strictEqual(handlers.size, 0);
292
293 const unpackaged = yield* PreviewPasskeys.PreviewPasskeys.pipe(
294 Effect.provide(
295 layerFor(
296 { t3codeWebAuthn: { touchIdKeychainAccessGroup: "X", browserPasskeys: true } },
297 false,
298 ),
299 ),
300 );
301 yield* unpackaged.configure;
302 assert.strictEqual(electron.configureWebAuthn.mock.calls.length, 1);
303 assert.isFalse(unpackaged.bridgeEnabled);
304 }),
305 );
306
307 it.effect("asks which passkey to use only when a site has several", () =>
308 Effect.gen(function* () {
309 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
310 const listeners: Array<(...args: ReadonlyArray<unknown>) => void> = [];
311 const session = {
312 on: (_event: string, listener: (...args: ReadonlyArray<unknown>) => void) =>
313 listeners.push(listener),
314 } as unknown as Electron.Session;
315 passkeys.installSessionHandlers(session);
316 passkeys.installSessionHandlers(session);
317 assert.strictEqual(listeners.length, 1);
318
319 const select = (accounts: ReadonlyArray<Electron.WebAuthnAccount>) =>
320 Effect.promise(
321 () =>
322 new Promise<unknown>((resolve) =>
323 listeners[0]?.(
324 {},
325 { relyingPartyId: "example.com", accounts, frame: null },
326 (credentialId?: string) => resolve(credentialId),
327 ),
328 ),
329 );
330 const alice = { credentialId: "alice", name: "alice@example.com" };
331 const bob = { credentialId: "bob", displayName: "Bob", name: "bob@example.com" };
332
333 assert.strictEqual(yield* select([alice]), "alice");
334 assert.strictEqual(electron.showMessageBox.mock.calls.length, 0);
335
336 electron.showMessageBox.mockResolvedValueOnce({ response: 1 });
337 assert.strictEqual(yield* select([alice, bob]), "bob");
338 assert.deepStrictEqual(electron.showMessageBox.mock.calls[0]?.[0]?.buttons, [
339 "alice@example.com",
340 "Bob (bob@example.com)",
341 "Cancel",
342 ]);
343
344 electron.showMessageBox.mockResolvedValueOnce({ response: 2 });
345 assert.isUndefined(yield* select([alice, bob]));
346 }).pipe(Effect.provide(layerFor({}))),
347 );
348});