authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 12:25:26-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 12:25:26-07:00
log454b94a13aea918f27bb060d8d054d4cfb791bc2
tree773c25e081c03a0b0792dad622ba9897c7b60103
parent0a4d789128100ed7ccd6d42f454946aea8edfca8
signature Signed by PGP key B5690EEEBB952194

feat(desktop): passkeys in the in-app browser on macOS (#16952)

Co-authored-by: Ben Davis <bmdavis419@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

24 files changed, 1762 insertions(+), 4 deletions(-)

apps/desktop/package.json+3-1
......@@ -27,9 +27,11 @@
2727 "electron": "44.4.5",
2828 "electron-store": "^8.2.0",
2929 "electron-updater": "^6.8.9",
30 "electron-webauthn": "catalog:",
3031 "ffi-rs": "1.3.2",
3132 "playwright-core": "1.60.0",
32 "react-grab": "^0.1.32"
33 "react-grab": "^0.1.32",
34 "tldts": "catalog:"
3335 },
3436 "devDependencies": {
3537 "@effect/vitest": "catalog:",
apps/desktop/src/app/DesktopApp.ts+3
......@@ -17,6 +17,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
1717import * as DesktopClerk from "./DesktopClerk.ts";
1818import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
1919import * as DesktopWindow from "../window/DesktopWindow.ts";
20import * as PreviewPasskeys from "../preview/Passkeys.ts";
2021import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
2122import * as DesktopEnvironment from "./DesktopEnvironment.ts";
2223import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
......@@ -276,6 +277,7 @@ const startup = Effect.gen(function* () {
276277 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
277278 const updates = yield* DesktopUpdates.DesktopUpdates;
278279 const environment = yield* DesktopEnvironment.DesktopEnvironment;
280 const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys;
279281
280282 yield* shellEnvironment.installIntoProcess;
281283 const hasCommandLinePasswordStore =
......@@ -329,6 +331,7 @@ const startup = Effect.gen(function* () {
329331 });
330332 }
331333 yield* appIdentity.configure;
334 yield* previewPasskeys.configure;
332335 yield* applicationMenu.configure;
333336 yield* updates.configure;
334337 yield* DesktopRemoteUpdates.listen;
apps/desktop/src/main.ts+2
......@@ -70,6 +70,7 @@ import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.
7070import * as BrowserSession from "./preview/BrowserSession.ts";
7171import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts";
7272import * as PreviewManager from "./preview/Manager.ts";
73import * as PreviewPasskeys from "./preview/Passkeys.ts";
7374import * as DesktopWindow from "./window/DesktopWindow.ts";
7475import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
7576import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";
......@@ -168,6 +169,7 @@ const layerDesktopPreview = PreviewManager.layer.pipe(
168169 // service alongside the manager; both sit on the same BrowserSession.
169170 Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
170171 Layer.provideMerge(BrowserSession.layer),
172 Layer.provideMerge(PreviewPasskeys.layer),
171173 Layer.provideMerge(layerDesktopFoundation),
172174);
173175
apps/desktop/src/preview-pick-preload.ts+9
......@@ -1 +1,10 @@
1import { ipcRenderer } from "electron";
2
3import { PASSKEY_BRIDGE_ARGUMENT } from "./preview/GuestProtocol.ts";
4import { installPasskeyBridge } from "./preview/PasskeyBridge.ts";
15import "./preview/PickPreload.ts";
6
7// Electron hands webPreferences.additionalArguments to sandboxed preloads in process.argv.
8if (process.argv.includes(PASSKEY_BRIDGE_ARGUMENT)) {
9 installPasskeyBridge((channel, publicKey) => ipcRenderer.invoke(channel, publicKey));
10}
apps/desktop/src/preview/GuestProtocol.ts+4
......@@ -11,3 +11,7 @@ export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer";
1111export const RECORDING_KEY_CHANNEL = "preview:recording-key";
1212export const RECORDING_INPUT_CHANNEL = "preview:recording-input";
1313export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller";
14export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create";
15export const PASSKEY_GET_CHANNEL = "preview:passkey-get";
16/** Renderer argument that turns on the guest passkey bridge; see Passkeys.ts. */
17export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge";
apps/desktop/src/preview/Manager.test.ts+8
......@@ -29,6 +29,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
2929import * as BrowserSession from "./BrowserSession.ts";
3030import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
3131import * as PreviewManager from "./Manager.ts";
32import * as PreviewPasskeys from "./Passkeys.ts";
3233
3334describe("fitPictureInPictureContentSize", () => {
3435 it("preserves the PiP content area across aspect-ratio changes", () => {
......@@ -301,6 +302,13 @@ const managerLayer = (platform: NodeJS.Platform = "darwin") =>
301302 ),
302303 Layer.provideMerge(DesktopBrowserHost.layer),
303304 Layer.provideMerge(layerBrowserSession),
305 Layer.provideMerge(
306 Layer.mock(PreviewPasskeys.PreviewPasskeys)({
307 bridgeEnabled: false,
308 installSessionHandlers: () => {},
309 attachGuest: () => () => {},
310 }),
311 ),
304312 Layer.provideMerge(layerEnvironment),
305313 Layer.provideMerge(layerFileSystem),
306314 Layer.provideMerge(Path.layer),
apps/desktop/src/preview/Manager.ts+7
......@@ -64,6 +64,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
6464import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
6565import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
6666import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
67import * as PreviewPasskeys from "./Passkeys.ts";
6768import * as BrowserSession from "./BrowserSession.ts";
6869import {
6970 ANNOTATION_CAPTURED_CHANNEL,
......@@ -548,6 +549,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
548549 const crypto = yield* Crypto.Crypto;
549550 const parentScope = yield* Scope.Scope;
550551 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
552 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
551553 const context = yield* Effect.context<never>();
552554 const runFork = Effect.runForkWith(context);
553555 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
......@@ -1254,6 +1256,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
12541256 ) {
12551257 const scope = yield* Scope.fork(parentScope, "sequential");
12561258 const attachmentId = Symbol();
1259 let detachPasskeys = () => {};
12571260 let documentId = 0;
12581261 let nextRequestId = 0;
12591262 let activeCapture: {
......@@ -1589,6 +1592,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
15891592 wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
15901593 wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput);
15911594 wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
1595 detachPasskeys();
15921596 }).pipe(Effect.ignore),
15931597 );
15941598 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
......@@ -1609,6 +1613,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
16091613 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
16101614 wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput);
16111615 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
1616 detachPasskeys = passkeys.attachGuest(wc);
16121617 wc.setWindowOpenHandler((details) => {
16131618 const action = previewWindowOpenAction(details);
16141619 if (action === "popup") {
......@@ -3686,6 +3691,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
36863691 const environment = yield* DesktopEnvironment.DesktopEnvironment;
36873692 const browserSession = yield* BrowserSession.BrowserSession;
36883693 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
3694 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
36893695 const downloadSessions = new WeakSet<Electron.Session>();
36903696 const fileSystem = yield* FileSystem.FileSystem;
36913697 const path = yield* Path.Path;
......@@ -3750,6 +3756,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
37503756 ),
37513757 );
37523758 placeServerDownloads(session);
3759 passkeys.installSessionHandlers(session);
37533760 return session;
37543761 },
37553762 ),
apps/desktop/src/preview/PasskeyAttestation.ts created+59
......@@ -0,0 +1,59 @@
1/**
2 * Reads the raw `authData` bytes out of a CBOR attestation object
3 * (`{ fmt, attStmt, authData }`). Returns undefined for anything malformed.
4 * Only the definite-length encodings authenticators emit are understood.
5 */
6export const authenticatorDataFromAttestation = (bytes: Uint8Array) => {
7 let offset = 0;
8 const readHead = () => {
9 const initial = bytes[offset++];
10 if (initial === undefined) return undefined;
11 const info = initial & 31;
12 let length = info;
13 if (info >= 24) {
14 if (info > 27) return undefined;
15 const size = 1 << (info - 24);
16 if (offset + size > bytes.length) return undefined;
17 length = 0;
18 for (let index = 0; index < size; index++) length = length * 256 + (bytes[offset++] ?? 0);
19 }
20 return { major: initial >> 5, length };
21 };
22 const skipValue = (): boolean => {
23 const head = readHead();
24 if (!head) return false;
25 switch (head.major) {
26 case 2:
27 case 3:
28 offset += head.length;
29 return offset <= bytes.length;
30 case 4:
31 for (let index = 0; index < head.length; index++) if (!skipValue()) return false;
32 return true;
33 case 5:
34 for (let index = 0; index < head.length * 2; index++) if (!skipValue()) return false;
35 return true;
36 case 6:
37 return skipValue();
38 default:
39 return true;
40 }
41 };
42
43 const map = readHead();
44 if (map?.major !== 5) return undefined;
45 for (let entry = 0; entry < map.length; entry++) {
46 const key = readHead();
47 if (key?.major !== 3 || offset + key.length > bytes.length) return undefined;
48 const name = new TextDecoder().decode(bytes.subarray(offset, offset + key.length));
49 offset += key.length;
50 if (name !== "authData") {
51 if (!skipValue()) return undefined;
52 continue;
53 }
54 const value = readHead();
55 if (value?.major !== 2 || offset + value.length > bytes.length) return undefined;
56 return bytes.slice(offset, offset + value.length);
57 }
58 return undefined;
59};
apps/desktop/src/preview/PasskeyBackend.ts created+26
......@@ -0,0 +1,26 @@
1import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts";
2
3/** What the shared bridge has already checked before a backend runs a ceremony. */
4export interface PasskeyCeremonyContext {
5 /** The committed origin of the frame that asked; never the page's own claim. */
6 readonly origin: string;
7 /** The window the system sheet attaches to. */
8 readonly nativeWindowHandle: Buffer;
9}
10
11/**
12 * One platform's way to create and use passkeys for a preview page. The
13 * bridge in `Passkeys.ts` owns everything platform-neutral: which frame may
14 * ask, focus, one ceremony at a time, the deadline, and dropping results for
15 * a page that navigated away. A backend only talks to the platform.
16 */
17export interface PasskeyBackend {
18 readonly create: (
19 publicKey: PublicKeyCredentialCreationOptions,
20 context: PasskeyCeremonyContext,
21 ) => Promise<PasskeyCeremonyResult>;
22 readonly get: (
23 publicKey: PublicKeyCredentialRequestOptions,
24 context: PasskeyCeremonyContext,
25 ) => Promise<PasskeyCeremonyResult>;
26}
apps/desktop/src/preview/PasskeyBackendMac.ts created+96
......@@ -0,0 +1,96 @@
1import { getPublicSuffix } from "tldts";
2
3import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts";
4import { authenticatorDataFromAttestation } from "./PasskeyAttestation.ts";
5import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts";
6
7const ES256 = -7;
8
9// WebAuthn rejects RP IDs that are public suffixes, private registries
10// included, so one github.io site cannot mint passkeys for every other one.
11const isPublicSuffix = (domain: string) =>
12 domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain;
13
14// Loaded only once a ceremony runs: the module and its native addon ship on macOS alone.
15const nativeOptions = async (context: PasskeyCeremonyContext) => ({
16 webauthn: await import("electron-webauthn"),
17 options: {
18 currentOrigin: context.origin,
19 topFrameOrigin: context.origin,
20 nativeWindowHandle: context.nativeWindowHandle,
21 isPublicSuffix,
22 },
23});
24
25const isBufferSource = (value: unknown): value is BufferSource =>
26 value instanceof ArrayBuffer || ArrayBuffer.isView(value);
27
28const toBase64Url = (value: BufferSource) =>
29 Buffer.from(
30 ArrayBuffer.isView(value)
31 ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength)
32 : new Uint8Array(value),
33 ).toString("base64url");
34
35/**
36 * macOS passkeys through AuthenticationServices (`electron-webauthn`): the
37 * system sheet offers iCloud Keychain, password managers, phones, and security
38 * keys. Needs Apple's managed browser entitlement in the signed build.
39 */
40export const macPasskeyBackend: PasskeyBackend = {
41 create: async (publicKey, context): Promise<PasskeyCeremonyResult> => {
42 // The native layer only converts P-256 keys and never settles for any
43 // other algorithm, so ES256 is the only one it may negotiate.
44 const params: unknown = publicKey.pubKeyCredParams;
45 if (params !== undefined && !Array.isArray(params))
46 return { success: false, error: "TypeError" };
47 const allowsEs256 =
48 params === undefined ||
49 params.length === 0 ||
50 params.some(
51 (param: unknown) =>
52 typeof param === "object" && param !== null && "alg" in param && param.alg === ES256,
53 );
54 if (!allowsEs256) return { success: false, error: "NotSupportedError" };
55 const { webauthn, options } = await nativeOptions(context);
56 const result = await webauthn.createCredential(
57 { ...publicKey, pubKeyCredParams: [{ type: "public-key", alg: ES256 }] },
58 options,
59 );
60 if (!result.success) return { success: false, error: result.error };
61 // The native layer reports parsed authenticator data as JSON and claims
62 // every credential is a synced platform passkey; neither is reliable.
63 const authData = authenticatorDataFromAttestation(
64 Buffer.from(result.data.attestationObject, "base64url"),
65 );
66 // Sites verify the new credential from its authenticator data; without it
67 // the passkey is unusable, so say so now rather than let sign-up fail later.
68 if (!authData) return { success: false, error: "NotAllowedError" };
69 return {
70 success: true,
71 data: {
72 ...result.data,
73 authData: Buffer.from(authData).toString("base64url"),
74 transports: [],
75 },
76 };
77 },
78 get: async (publicKey, context): Promise<PasskeyCeremonyResult> => {
79 const { webauthn, options } = await nativeOptions(context);
80 // WebAuthn defaults the RP ID to the caller's host; the native layer requires it.
81 const result = await webauthn.getCredential(
82 { ...publicKey, rpId: publicKey.rpId ?? new URL(context.origin).hostname },
83 options,
84 );
85 if (!result.success) return { success: false, error: result.error };
86 // The native layer applies the site's allow list to platform passkeys only,
87 // so a security key can answer with a credential the site did not ask for.
88 const allowed = (Array.isArray(publicKey.allowCredentials) ? publicKey.allowCredentials : [])
89 .filter((credential) => credential?.type === "public-key" && isBufferSource(credential.id))
90 .map((credential) => toBase64Url(credential.id));
91 if (allowed.length > 0 && !allowed.includes(result.data.credentialId)) {
92 return { success: false, error: "NotAllowedError" };
93 }
94 return result;
95 },
96};
apps/desktop/src/preview/PasskeyBridge.test.ts created+246
......@@ -0,0 +1,246 @@
1import { assert, describe, it } from "@effect/vitest";
2import { afterEach, vi } from "vite-plus/test";
3
4import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts";
5import { installPasskeyBridge } from "./PasskeyBridge.ts";
6
7const base64Url = (bytes: ReadonlyArray<number>) => Buffer.from(bytes).toString("base64url");
8const bytesOf = (buffer: ArrayBuffer | null) => (buffer ? [...new Uint8Array(buffer)] : null);
9
10const assertionData = {
11 credentialId: base64Url([1, 2, 3]),
12 clientDataJSON: base64Url([4]),
13 authenticatorData: base64Url([5]),
14 signature: base64Url([6, 7]),
15 userHandle: base64Url([8]),
16 extensions: { prf: { results: { first: base64Url([9]) } } },
17};
18
19const attestationData = {
20 credentialId: base64Url([1, 2, 3]),
21 clientDataJSON: base64Url([4]),
22 attestationObject: base64Url([10]),
23 authData: base64Url([11]),
24 publicKey: base64Url([12]),
25 publicKeyAlgorithm: -7,
26 transports: [],
27 extensions: { credProps: { rk: true } },
28};
29
30const creationOptions: PublicKeyCredentialCreationOptions = {
31 challenge: new Uint8Array([1]),
32 rp: { name: "Example" },
33 user: { id: new Uint8Array([2]), name: "alice", displayName: "Alice" },
34 pubKeyCredParams: [{ type: "public-key", alg: -7 }],
35};
36
37const illegalInvocation = (): never => {
38 throw new TypeError("Illegal invocation");
39};
40
41/**
42 * Installs the bridge over fresh stand-ins for the page's DOM classes. Like
43 * Chromium's, their getters only work on objects the browser created itself.
44 */
45const install = (respond: (channel: string) => unknown) => {
46 const nativeCreate = vi.fn(async () => "native-create");
47 const nativeGet = vi.fn(async () => "native-get");
48 class FakeCredentialsContainer {
49 create() {
50 return nativeCreate();
51 }
52 get() {
53 return nativeGet();
54 }
55 }
56 class FakePublicKeyCredential {
57 get id() {
58 return illegalInvocation();
59 }
60 get response() {
61 return illegalInvocation();
62 }
63 }
64 class FakeAttestationResponse {
65 get clientDataJSON() {
66 return illegalInvocation();
67 }
68 }
69 class FakeAssertionResponse {
70 get signature() {
71 return illegalInvocation();
72 }
73 }
74 vi.stubGlobal("CredentialsContainer", FakeCredentialsContainer);
75 vi.stubGlobal("PublicKeyCredential", FakePublicKeyCredential);
76 vi.stubGlobal("AuthenticatorAttestationResponse", FakeAttestationResponse);
77 vi.stubGlobal("AuthenticatorAssertionResponse", FakeAssertionResponse);
78
79 const invoke = vi.fn(async (channel: string, _publicKey: unknown) => respond(channel));
80 installPasskeyBridge(invoke);
81 return {
82 invoke,
83 nativeCreate,
84 nativeGet,
85 credentials: new FakeCredentialsContainer() as unknown as CredentialsContainer,
86 FakePublicKeyCredential,
87 FakeAttestationResponse,
88 FakeAssertionResponse,
89 };
90};
91
92describe("installPasskeyBridge", () => {
93 afterEach(() => {
94 vi.unstubAllGlobals();
95 });
96
97 it("serves passkey sign-in through the main process with a credential pages can verify", async () => {
98 const { invoke, credentials, FakePublicKeyCredential, FakeAssertionResponse } = install(() => ({
99 success: true,
100 data: assertionData,
101 }));
102 const challenge = new Uint8Array([0, 1, 2, 3]).subarray(1);
103
104 const credential = (await credentials.get({
105 publicKey: {
106 challenge,
107 rpId: "example.com",
108 allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }],
109 // Unknown members the page adds must not break IPC cloning.
110 extensions: { onHint: () => {} } as AuthenticationExtensionsClientInputs,
111 },
112 })) as PublicKeyCredential;
113
114 assert.deepStrictEqual(invoke.mock.calls[0], [
115 PASSKEY_GET_CHANNEL,
116 {
117 challenge: new Uint8Array([1, 2, 3]),
118 rpId: "example.com",
119 allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }],
120 extensions: {},
121 },
122 ]);
123 const response = credential.response as AuthenticatorAssertionResponse;
124 assert.instanceOf(credential, FakePublicKeyCredential);
125 assert.instanceOf(response, FakeAssertionResponse);
126 assert.strictEqual(credential.id, assertionData.credentialId);
127 assert.deepStrictEqual(bytesOf(credential.rawId), [1, 2, 3]);
128 assert.deepStrictEqual(bytesOf(response.signature), [6, 7]);
129 assert.deepStrictEqual(bytesOf(response.userHandle), [8]);
130 assert.deepStrictEqual(
131 bytesOf(credential.getClientExtensionResults().prf?.results?.first as ArrayBuffer),
132 [9],
133 );
134 assert.deepStrictEqual(credential.toJSON(), {
135 id: assertionData.credentialId,
136 rawId: assertionData.credentialId,
137 type: "public-key",
138 response: {
139 clientDataJSON: assertionData.clientDataJSON,
140 authenticatorData: assertionData.authenticatorData,
141 signature: assertionData.signature,
142 userHandle: assertionData.userHandle,
143 },
144 clientExtensionResults: { prf: { results: { first: base64Url([9]) } } },
145 });
146 });
147
148 it("returns registrations with their attestation and public key", async () => {
149 const { invoke, credentials, FakeAttestationResponse } = install(() => ({
150 success: true,
151 data: attestationData,
152 }));
153
154 const credential = (await credentials.create({
155 publicKey: creationOptions,
156 })) as PublicKeyCredential;
157
158 assert.strictEqual(invoke.mock.calls[0]?.[0], PASSKEY_CREATE_CHANNEL);
159 const response = credential.response as AuthenticatorAttestationResponse;
160 assert.instanceOf(response, FakeAttestationResponse);
161 assert.isNull(credential.authenticatorAttachment);
162 assert.deepStrictEqual(bytesOf(response.attestationObject), [10]);
163 assert.deepStrictEqual(bytesOf(response.getAuthenticatorData()), [11]);
164 assert.deepStrictEqual(bytesOf(response.getPublicKey()), [12]);
165 assert.strictEqual(response.getPublicKeyAlgorithm(), -7);
166 assert.deepStrictEqual(credential.getClientExtensionResults(), { credProps: { rk: true } });
167 assert.deepStrictEqual(credential.toJSON(), {
168 id: attestationData.credentialId,
169 rawId: attestationData.credentialId,
170 type: "public-key",
171 response: {
172 clientDataJSON: attestationData.clientDataJSON,
173 attestationObject: attestationData.attestationObject,
174 authenticatorData: attestationData.authData,
175 transports: [],
176 publicKey: attestationData.publicKey,
177 publicKeyAlgorithm: -7,
178 },
179 clientExtensionResults: { credProps: { rk: true } },
180 });
181 });
182
183 it("leaves autofill and non-passkey requests to Chromium", async () => {
184 const { invoke, credentials, nativeCreate, nativeGet } = install(() => ({
185 success: true,
186 data: assertionData,
187 }));
188
189 await credentials.get({
190 mediation: "conditional",
191 publicKey: { challenge: new Uint8Array([1]) },
192 });
193 await credentials.get({ password: true } as CredentialRequestOptions);
194 // Conditional create upgrades a password sign-in silently; no modal sheet.
195 const upgrade = { mediation: "conditional", publicKey: creationOptions } as const;
196 await credentials.create(upgrade);
197 await credentials.create({ password: {} } as CredentialCreationOptions);
198 assert.strictEqual(invoke.mock.calls.length, 0);
199 assert.strictEqual(nativeGet.mock.calls.length, 2);
200 assert.strictEqual(nativeCreate.mock.calls.length, 2);
201 assert.isFalse(await PublicKeyCredential.isConditionalMediationAvailable());
202 assert.isTrue(await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable());
203 });
204
205 it("rejects with the WebAuthn error the ceremony ended with", async () => {
206 const { credentials } = install((channel) => ({
207 success: false,
208 error: channel === PASSKEY_CREATE_CHANNEL ? "InvalidStateError" : "TypeError",
209 }));
210 const created = await credentials
211 .create({ publicKey: creationOptions })
212 .catch((error: unknown) => error);
213 assert.instanceOf(created, DOMException);
214 assert.strictEqual((created as DOMException).name, "InvalidStateError");
215 const fetched = await credentials
216 .get({ publicKey: { challenge: new Uint8Array([1]) } })
217 .catch((error: unknown) => error);
218 assert.instanceOf(fetched, TypeError);
219 });
220
221 it("refuses when the main process has no handler for the page", async () => {
222 const { credentials } = install(() => {
223 throw new Error("Error invoking remote method 'preview:passkey-get': No handler registered");
224 });
225
226 const error = await credentials
227 .get({ publicKey: { challenge: new Uint8Array([1]) } })
228 .catch((caught: unknown) => caught);
229 assert.instanceOf(error, DOMException);
230 assert.strictEqual((error as DOMException).name, "NotAllowedError");
231 });
232
233 it("rejects as soon as the page aborts, without waiting on the system sheet", async () => {
234 const { credentials } = install(() => new Promise(() => {}));
235 const controller = new AbortController();
236
237 const pending = credentials
238 .get({ publicKey: { challenge: new Uint8Array([1]) }, signal: controller.signal })
239 .catch((error: unknown) => error);
240 controller.abort();
241
242 const error = await pending;
243 assert.instanceOf(error, DOMException);
244 assert.strictEqual((error as DOMException).name, "AbortError");
245 });
246});
apps/desktop/src/preview/PasskeyBridge.ts created+286
......@@ -0,0 +1,286 @@
1import type { CreateCredentialResult, GetCredentialResult } from "electron-webauthn";
2
3import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts";
4
5/** What the main process answers a guest passkey ceremony with. */
6export type PasskeyCeremonyResult =
7 | Extract<CreateCredentialResult, { success: true }>
8 | Extract<GetCredentialResult, { success: true }>
9 | { readonly success: false; readonly error: string };
10
11type PasskeyChannel = typeof PASSKEY_CREATE_CHANNEL | typeof PASSKEY_GET_CHANNEL;
12// Credential Management Level 1 added `mediation` to creation requests.
13type CreationOptions = CredentialCreationOptions & {
14 readonly mediation?: CredentialMediationRequirement;
15};
16type InvokePasskeyCeremony = (channel: PasskeyChannel, publicKey: unknown) => Promise<unknown>;
17
18const NOT_ALLOWED_MESSAGE =
19 "The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client.";
20const DOM_EXCEPTION_NAMES = new Set([
21 "AbortError",
22 "InvalidStateError",
23 "NotAllowedError",
24 "NotSupportedError",
25 "SecurityError",
26]);
27
28const fromBase64Url = (value: string) => {
29 const base64 = value.replaceAll("-", "+").replaceAll("_", "/");
30 const binary = atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "="));
31 return Uint8Array.from(binary, (character) => character.charCodeAt(0)).buffer;
32};
33
34const toBase64Url = (buffer: ArrayBuffer) =>
35 btoa(String.fromCharCode(...new Uint8Array(buffer)))
36 .replaceAll("+", "-")
37 .replaceAll("/", "_")
38 .replace(/=+$/u, "");
39
40const ceremonyError = (name: string) =>
41 name === "TypeError"
42 ? new TypeError("The passkey request options are invalid.")
43 : new DOMException(
44 NOT_ALLOWED_MESSAGE,
45 DOM_EXCEPTION_NAMES.has(name) ? name : "NotAllowedError",
46 );
47
48/**
49 * Copies request options into plain IPC-cloneable data. Chromium ignores
50 * members it does not know, so functions and other uncloneable values drop out
51 * here instead of failing the whole ceremony.
52 */
53const toCloneable = (value: unknown): unknown => {
54 if (value instanceof ArrayBuffer) return value.slice(0);
55 if (ArrayBuffer.isView(value)) {
56 return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice();
57 }
58 if (Array.isArray(value)) return value.map(toCloneable);
59 if (typeof value === "object" && value !== null) {
60 return Object.fromEntries(
61 Object.entries(value).flatMap(([key, member]) =>
62 typeof member === "function" || member === undefined ? [] : [[key, toCloneable(member)]],
63 ),
64 );
65 }
66 return value;
67};
68
69// The objects below borrow the native prototypes so `instanceof` checks pass.
70// Their own properties shadow the native getters, which only work on objects
71// Chromium created itself.
72const withValues = <T extends object>(target: T, values: Record<string, unknown>) => {
73 for (const [key, value] of Object.entries(values)) {
74 Object.defineProperty(target, key, { value, enumerable: true, configurable: true });
75 }
76 return target;
77};
78
79const prfOutputs = (prf: {
80 readonly enabled?: boolean;
81 readonly results?: { readonly first?: string; readonly second?: string };
82}): AuthenticationExtensionsPRFOutputs => {
83 const first = prf.results?.first;
84 const second = prf.results?.second;
85 return {
86 ...(prf.enabled === undefined ? {} : { enabled: prf.enabled }),
87 ...(first === undefined
88 ? {}
89 : {
90 results: {
91 first: fromBase64Url(first),
92 ...(second === undefined ? {} : { second: fromBase64Url(second) }),
93 },
94 }),
95 };
96};
97
98const extensionResultsJson = (results: AuthenticationExtensionsClientOutputs) =>
99 JSON.parse(
100 JSON.stringify(results, (_key, value: unknown) =>
101 value instanceof ArrayBuffer ? toBase64Url(value) : value,
102 ),
103 ) as unknown;
104
105const makeCredential = (input: {
106 readonly id: string;
107 readonly authenticatorAttachment: AuthenticatorAttachment | null;
108 readonly response: AuthenticatorResponse;
109 readonly responseJson: Record<string, unknown>;
110 readonly extensionResults: AuthenticationExtensionsClientOutputs;
111}) =>
112 withValues(Object.create(PublicKeyCredential.prototype) as PublicKeyCredential, {
113 id: input.id,
114 rawId: fromBase64Url(input.id),
115 type: "public-key",
116 authenticatorAttachment: input.authenticatorAttachment,
117 response: input.response,
118 getClientExtensionResults: () => input.extensionResults,
119 toJSON: () => ({
120 id: input.id,
121 rawId: input.id,
122 type: "public-key",
123 ...(input.authenticatorAttachment === null
124 ? {}
125 : { authenticatorAttachment: input.authenticatorAttachment }),
126 response: input.responseJson,
127 clientExtensionResults: extensionResultsJson(input.extensionResults),
128 }),
129 });
130
131const credentialFromCreateResult = (
132 data: Extract<CreateCredentialResult, { success: true }>["data"],
133) => {
134 const transports = [...data.transports];
135 const publicKey = data.publicKey.length > 0 ? data.publicKey : null;
136 const extensionResults: AuthenticationExtensionsClientOutputs = {
137 ...(data.extensions.credProps ? { credProps: { ...data.extensions.credProps } } : {}),
138 ...(data.extensions.prf ? { prf: prfOutputs(data.extensions.prf) } : {}),
139 ...(data.extensions.largeBlob ? { largeBlob: { ...data.extensions.largeBlob } } : {}),
140 };
141 return makeCredential({
142 id: data.credentialId,
143 // The native layer cannot tell a synced passkey from a security key.
144 authenticatorAttachment: null,
145 response: withValues(
146 Object.create(AuthenticatorAttestationResponse.prototype) as AuthenticatorAttestationResponse,
147 {
148 clientDataJSON: fromBase64Url(data.clientDataJSON),
149 attestationObject: fromBase64Url(data.attestationObject),
150 getTransports: () => [...transports],
151 getAuthenticatorData: () => fromBase64Url(data.authData),
152 getPublicKey: () => (publicKey === null ? null : fromBase64Url(publicKey)),
153 getPublicKeyAlgorithm: () => data.publicKeyAlgorithm,
154 },
155 ),
156 responseJson: {
157 clientDataJSON: data.clientDataJSON,
158 attestationObject: data.attestationObject,
159 authenticatorData: data.authData,
160 transports,
161 ...(publicKey === null ? {} : { publicKey }),
162 publicKeyAlgorithm: data.publicKeyAlgorithm,
163 },
164 extensionResults,
165 });
166};
167
168const credentialFromGetResult = (data: Extract<GetCredentialResult, { success: true }>["data"]) => {
169 const userHandle = data.userHandle.length > 0 ? data.userHandle : null;
170 const { prf, largeBlob } = data.extensions ?? {};
171 const extensionResults: AuthenticationExtensionsClientOutputs = {
172 ...(prf ? { prf: prfOutputs(prf) } : {}),
173 ...(largeBlob
174 ? {
175 largeBlob: {
176 ...(largeBlob.blob === undefined ? {} : { blob: fromBase64Url(largeBlob.blob) }),
177 ...(largeBlob.written === undefined ? {} : { written: largeBlob.written }),
178 },
179 }
180 : {}),
181 };
182 return makeCredential({
183 id: data.credentialId,
184 authenticatorAttachment: null,
185 response: withValues(
186 Object.create(AuthenticatorAssertionResponse.prototype) as AuthenticatorAssertionResponse,
187 {
188 clientDataJSON: fromBase64Url(data.clientDataJSON),
189 authenticatorData: fromBase64Url(data.authenticatorData),
190 signature: fromBase64Url(data.signature),
191 userHandle: userHandle === null ? null : fromBase64Url(userHandle),
192 },
193 ),
194 responseJson: {
195 clientDataJSON: data.clientDataJSON,
196 authenticatorData: data.authenticatorData,
197 signature: data.signature,
198 ...(userHandle === null ? {} : { userHandle }),
199 },
200 extensionResults,
201 });
202};
203
204const abortReason = (signal: AbortSignal) =>
205 signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
206
207/**
208 * Routes this page's WebAuthn ceremonies to the system passkey sheet through
209 * the main process, which supplies the frame's real origin. Runs in the page's
210 * own world (the preview preload has contextIsolation off), before any page
211 * script. Conditional (autofill and automatic upgrade) requests stay native:
212 * the system sheet is modal and must not open on its own.
213 */
214export function installPasskeyBridge(invoke: InvokePasskeyCeremony) {
215 // Insecure contexts have no WebAuthn to bridge.
216 if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") {
217 return;
218 }
219 const run = async (
220 channel: PasskeyChannel,
221 publicKey: unknown,
222 signal: AbortSignal | undefined,
223 ): Promise<PublicKeyCredential> => {
224 if (signal?.aborted) throw abortReason(signal);
225 const ceremony = invoke(channel, toCloneable(publicKey)).then(
226 (value) => {
227 const result = value as PasskeyCeremonyResult;
228 if (!result.success) throw ceremonyError(result.error);
229 return "attestationObject" in result.data
230 ? credentialFromCreateResult(result.data)
231 : credentialFromGetResult(result.data);
232 },
233 // No handler yet (or any more) for this guest: answer like a refusal.
234 (error: unknown) => {
235 throw error instanceof DOMException || error instanceof TypeError
236 ? error
237 : ceremonyError("NotAllowedError");
238 },
239 );
240 if (!signal) return ceremony;
241 return new Promise((resolve, reject) => {
242 const onAbort = () => reject(abortReason(signal));
243 signal.addEventListener("abort", onAbort, { once: true });
244 ceremony.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
245 });
246 };
247
248 const container = CredentialsContainer.prototype;
249 const nativeCreate = container.create;
250 const nativeGet = container.get;
251 const define = (target: object, key: string, value: unknown) =>
252 Object.defineProperty(target, key, { value, configurable: true, writable: true });
253
254 define(
255 container,
256 "create",
257 function create(this: CredentialsContainer, options?: CreationOptions) {
258 return options?.publicKey && options.mediation !== "conditional"
259 ? run(PASSKEY_CREATE_CHANNEL, options.publicKey, options.signal)
260 : Reflect.apply(nativeCreate, this, [options]);
261 },
262 );
263 define(
264 container,
265 "get",
266 function get(this: CredentialsContainer, options?: CredentialRequestOptions) {
267 return options?.publicKey && options.mediation !== "conditional"
268 ? run(PASSKEY_GET_CHANNEL, options.publicKey, options.signal)
269 : Reflect.apply(nativeGet, this, [options]);
270 },
271 );
272
273 define(PublicKeyCredential, "isUserVerifyingPlatformAuthenticatorAvailable", async () => true);
274 define(PublicKeyCredential, "isConditionalMediationAvailable", async () => false);
275 const nativeCapabilities = PublicKeyCredential.getClientCapabilities;
276 if (typeof nativeCapabilities === "function") {
277 define(PublicKeyCredential, "getClientCapabilities", async () => ({
278 ...(await Reflect.apply(nativeCapabilities, PublicKeyCredential, [])),
279 conditionalCreate: false,
280 conditionalGet: false,
281 hybridTransport: true,
282 passkeyPlatformAuthenticator: true,
283 userVerifyingPlatformAuthenticator: true,
284 }));
285 }
286}
apps/desktop/src/preview/Passkeys.test.ts created+374
......@@ -0,0 +1,374 @@
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});
apps/desktop/src/preview/Passkeys.ts created+242
......@@ -0,0 +1,242 @@
1// @effect-diagnostics globalTimers:off - Ceremonies settle inside IPC handlers, outside an Effect runtime.
2import type { IpcMainInvokeEvent, Session, WebContents, WebFrameMain } from "electron";
3import { app, BrowserWindow, dialog, webContents as electronWebContents } from "electron";
4import * as Context from "effect/Context";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Schema from "effect/Schema";
9
10import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
11import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts";
12import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts";
13import { macPasskeyBackend } from "./PasskeyBackendMac.ts";
14import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts";
15
16const PasskeyPackageMetadata = Schema.Struct({
17 t3codeWebAuthn: Schema.optional(
18 Schema.Struct({
19 touchIdKeychainAccessGroup: Schema.optional(Schema.String),
20 browserPasskeys: Schema.optional(Schema.Boolean),
21 }),
22 ),
23});
24const decodePasskeyPackageMetadata = Schema.decodeEffect(
25 Schema.fromJsonString(PasskeyPackageMetadata),
26);
27
28export class PreviewPasskeysConfigureError extends Schema.TaggedError<PreviewPasskeysConfigureError>()(
29 "PreviewPasskeysConfigureError",
30 {
31 keychainAccessGroup: Schema.String,
32 cause: Schema.Defect(),
33 },
34) {
35 override get message(): string {
36 return `Failed to enable Touch ID passkeys for the in-app browser (keychain group ${this.keychainAccessGroup}).`;
37 }
38}
39
40/**
41 * Passkeys for in-app browser pages on macOS, where Electron has no WebAuthn UI
42 * of its own. Signed builds record which entitlements their provisioning
43 * profile granted (scripts/build-desktop-artifact.ts), and each path turns on
44 * only when its entitlement is present:
45 *
46 * - Touch ID: Electron's built-in platform authenticator. Passkeys it creates
47 * stay on this Mac, in T3 Code's keychain group.
48 * - Browser passkeys: Apple's managed browser entitlement lets the system sheet
49 * (iCloud Keychain, password managers, phones, security keys) serve any
50 * site. The guest preload hands each ceremony to this process, which pins
51 * the requesting frame's real origin.
52 *
53 * Windows needs neither: Chromium already uses Windows Hello there.
54 */
55export class PreviewPasskeys extends Context.Service<
56 PreviewPasskeys,
57 {
58 /** Whether preview pages route WebAuthn through the system passkey sheet. */
59 readonly bridgeEnabled: boolean;
60 /** Turns on Touch ID passkeys when this build is entitled to them. Runs after app ready. */
61 readonly configure: Effect.Effect<void>;
62 /** Lets the user choose between several passkeys for one site. Idempotent. */
63 readonly installSessionHandlers: (session: Session) => void;
64 /** Serves a preview guest's passkey ceremonies. Returns the detach function. */
65 readonly attachGuest: (guest: WebContents) => () => void;
66 }
67>()("@t3tools/desktop/preview/Passkeys/PreviewPasskeys") {}
68
69const notAllowed: PasskeyCeremonyResult = { success: false, error: "NotAllowedError" };
70
71const accountLabel = (account: Electron.WebAuthnAccount) =>
72 account.displayName && account.name && account.displayName !== account.name
73 ? `${account.displayName} (${account.name})`
74 : (account.name ?? account.displayName ?? "Unnamed passkey");
75
76const ownerWindow = (frame: WebFrameMain | null) => {
77 const contents = frame ? electronWebContents.fromFrame(frame) : undefined;
78 const owner = contents?.hostWebContents ?? contents;
79 return owner ? BrowserWindow.fromWebContents(owner) : null;
80};
81
82const chooseAccount = async (details: Electron.SelectWebauthnAccountDetails) => {
83 const [onlyAccount] = details.accounts;
84 // The Touch ID prompt that follows already asks the user to confirm.
85 if (details.accounts.length === 1) return onlyAccount?.credentialId;
86 const options: Electron.MessageBoxOptions = {
87 type: "none",
88 message: "Choose a passkey",
89 detail: details.relyingPartyId,
90 buttons: [...details.accounts.map(accountLabel), "Cancel"],
91 cancelId: details.accounts.length,
92 defaultId: 0,
93 noLink: true,
94 };
95 const parent = ownerWindow(details.frame);
96 const { response } = parent
97 ? await dialog.showMessageBox(parent, options)
98 : await dialog.showMessageBox(options);
99 return details.accounts[response]?.credentialId;
100};
101
102const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
103const NATIVE_MAX_TIMEOUT_MS = 60 * 60 * 1000;
104
105// Mirrors the native layer's own clamp, plus a margin: it can leave a ceremony
106// unsettled, and the page must still get an answer.
107const ceremonyDeadline = (timeout: unknown) =>
108 (typeof timeout === "number" && timeout > 0
109 ? Math.min(timeout, NATIVE_MAX_TIMEOUT_MS)
110 : NATIVE_DEFAULT_TIMEOUT_MS) + 5_000;
111
112const withDeadline = (ceremony: Promise<PasskeyCeremonyResult>, milliseconds: number) => {
113 let timer: ReturnType<typeof setTimeout> | undefined;
114 const deadline = new Promise<PasskeyCeremonyResult>((resolve) => {
115 timer = setTimeout(() => resolve(notAllowed), milliseconds);
116 });
117 return Promise.race([ceremony, deadline]).finally(() => clearTimeout(timer));
118};
119
120// Secure contexts only: https, or http on this machine. Chromium also trusts
121// `*.localhost`, but the macOS layer refuses it, so it is turned away here
122// instead of failing deeper with a less useful error.
123const isTrustworthyOrigin = (origin: string) => {
124 if (!URL.canParse(origin)) return false;
125 const { protocol, hostname } = new URL(origin);
126 return (
127 protocol === "https:" ||
128 (protocol === "http:" &&
129 (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"))
130 );
131};
132
133/**
134 * The platform that serves preview pages' passkey ceremonies, or none. Windows
135 * needs none: Chromium already hands WebAuthn to Windows Hello there.
136 */
137const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined =>
138 platform === "darwin" ? macPasskeyBackend : undefined;
139
140const make = Effect.gen(function* () {
141 const environment = yield* DesktopEnvironment.DesktopEnvironment;
142 const fileSystem = yield* FileSystem.FileSystem;
143
144 const metadata =
145 environment.platform === "darwin" && environment.isPackaged
146 ? yield* fileSystem
147 .readFileString(environment.path.join(environment.appRoot, "package.json"))
148 .pipe(
149 Effect.flatMap(decodePasskeyPackageMetadata),
150 Effect.map((parsed) => parsed.t3codeWebAuthn),
151 Effect.orElseSucceed(() => undefined),
152 )
153 : undefined;
154 const keychainAccessGroup = metadata?.touchIdKeychainAccessGroup;
155 const backend = metadata?.browserPasskeys === true ? backendFor(environment.platform) : undefined;
156 const bridgeEnabled = backend !== undefined;
157 const sessionsWithHandlers = new WeakSet<Session>();
158
159 return PreviewPasskeys.of({
160 bridgeEnabled,
161 configure: Effect.gen(function* () {
162 if (keychainAccessGroup === undefined) return;
163 yield* Effect.try({
164 try: () => app.configureWebAuthn({ touchID: { keychainAccessGroup } }),
165 catch: (cause) => new PreviewPasskeysConfigureError({ keychainAccessGroup, cause }),
166 }).pipe(Effect.catch((error) => Effect.logWarning(error.message, { cause: error.cause })));
167 }).pipe(Effect.withSpan("desktop.previewPasskeys.configure")),
168 installSessionHandlers: (session) => {
169 if (sessionsWithHandlers.has(session)) return;
170 sessionsWithHandlers.add(session);
171 session.on("select-webauthn-account", (_event, details, callback) => {
172 // The request stays pending until the callback runs, so it must run once.
173 void chooseAccount(details).then(
174 (credentialId) => callback(credentialId),
175 () => callback(),
176 );
177 });
178 },
179 attachGuest: (guest) => {
180 if (backend === undefined) return () => {};
181 let ceremonyPending = false;
182 const serve = async (
183 event: IpcMainInvokeEvent,
184 publicKey: { readonly timeout?: unknown } | undefined,
185 ceremony: (context: PasskeyCeremonyContext) => Promise<PasskeyCeremonyResult>,
186 ): Promise<PasskeyCeremonyResult> => {
187 // Only the guest's main frame runs the bridge preload.
188 const frame = event.senderFrame;
189 if (!frame || frame !== guest.mainFrame) return notAllowed;
190 // The page shares a JS world with the preload, so nothing it sends can
191 // be trusted for the origin. Read the committed origin before any await:
192 // the frame object outlives a navigation.
193 const origin = frame.origin;
194 // Like Chromium, only a focused page may open the sheet, one at a time.
195 if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) {
196 return notAllowed;
197 }
198 if (typeof publicKey !== "object" || publicKey === null) {
199 return { success: false, error: "TypeError" };
200 }
201 const host = guest.hostWebContents
202 ? BrowserWindow.fromWebContents(guest.hostWebContents)
203 : null;
204 if (!host || host.isDestroyed()) return notAllowed;
205
206 ceremonyPending = true;
207 try {
208 const result = await withDeadline(
209 ceremony({ origin, nativeWindowHandle: host.getNativeWindowHandle() }),
210 ceremonyDeadline(publicKey.timeout),
211 );
212 // A credential minted for a document that navigated away belongs to nobody.
213 const sameDocument =
214 !frame.isDestroyed() && frame === guest.mainFrame && frame.origin === origin;
215 return sameDocument ? result : notAllowed;
216 } catch {
217 return notAllowed;
218 } finally {
219 ceremonyPending = false;
220 }
221 };
222 const detach = () => {
223 guest.ipc.removeHandler(PASSKEY_CREATE_CHANNEL);
224 guest.ipc.removeHandler(PASSKEY_GET_CHANNEL);
225 };
226 detach();
227 // Option shapes are the page's to get wrong: the native layer validates
228 // them and answers with a TypeError.
229 guest.ipc.handle(
230 PASSKEY_CREATE_CHANNEL,
231 (event, publicKey: PublicKeyCredentialCreationOptions) =>
232 serve(event, publicKey, (context) => backend.create(publicKey, context)),
233 );
234 guest.ipc.handle(PASSKEY_GET_CHANNEL, (event, publicKey: PublicKeyCredentialRequestOptions) =>
235 serve(event, publicKey, (context) => backend.get(publicKey, context)),
236 );
237 return detach;
238 },
239 });
240}).pipe(Effect.withSpan("PreviewPasskeys.make"));
241
242export const layer = Layer.effect(PreviewPasskeys, make);
apps/desktop/src/window/DesktopWindow.test.ts+11
......@@ -56,6 +56,7 @@ import {
5656import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
5757import * as DesktopWindow from "./DesktopWindow.ts";
5858import * as PreviewManager from "../preview/Manager.ts";
59import * as PreviewPasskeys from "../preview/Passkeys.ts";
5960
6061const environmentInput = {
6162 dirname: "/repo/apps/desktop/dist-electron",
......@@ -325,6 +326,11 @@ function layerTest(input: {
325326 } satisfies ElectronShell.ElectronShell["Service"]),
326327 layerElectronTheme,
327328 layerElectronWindow,
329 Layer.mock(PreviewPasskeys.PreviewPasskeys)({
330 bridgeEnabled: false,
331 installSessionHandlers: () => {},
332 attachGuest: () => () => {},
333 }),
328334 Layer.mock(PreviewManager.PreviewManager)({
329335 getBrowserSession: () => Effect.succeed({} as Electron.Session),
330336 setMainWindow: () => Effect.void,
......@@ -433,6 +439,11 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
433439 } satisfies ElectronShell.ElectronShell["Service"]),
434440 layerElectronTheme,
435441 Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape),
442 Layer.mock(PreviewPasskeys.PreviewPasskeys)({
443 bridgeEnabled: false,
444 installSessionHandlers: () => {},
445 attachGuest: () => () => {},
446 }),
436447 Layer.mock(PreviewManager.PreviewManager)({
437448 getBrowserSession: () => Effect.succeed({} as Electron.Session),
438449 setMainWindow: () => Effect.void,
apps/desktop/src/window/DesktopWindow.ts+9
......@@ -25,7 +25,9 @@ import {
2525 TRACKPAD_SCROLL_END_CHANNEL,
2626 WINDOW_FULLSCREEN_STATE_CHANNEL,
2727} from "../ipc/channels.ts";
28import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts";
2829import * as PreviewManager from "../preview/Manager.ts";
30import * as PreviewPasskeys from "../preview/Passkeys.ts";
2931import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
3032import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
3133import * as ElectronApp from "../electron/ElectronApp.ts";
......@@ -326,6 +328,7 @@ export const make = Effect.gen(function* () {
326328 const electronTheme = yield* ElectronTheme.ElectronTheme;
327329 const electronWindow = yield* ElectronWindow.ElectronWindow;
328330 const previewManager = yield* PreviewManager.PreviewManager;
331 const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys;
329332 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
330333 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
331334 const electronApp = yield* ElectronApp.ElectronApp;
......@@ -537,6 +540,12 @@ export const make = Effect.gen(function* () {
537540 webPreferences.nodeIntegrationInSubFrames = false;
538541 webPreferences.contextIsolation = false;
539542 webPreferences.disableHtmlFullscreenWindowResize = true;
543 if (previewPasskeys.bridgeEnabled) {
544 webPreferences.additionalArguments = [
545 ...(webPreferences.additionalArguments ?? []),
546 PASSKEY_BRIDGE_ARGUMENT,
547 ];
548 }
540549 });
541550
542551 const contextMenuContents = new WeakSet<Electron.WebContents>();
docs/operations/release.md+10
......@@ -553,6 +553,16 @@ Notes:
553553- The workflow writes it to a temporary `AuthKey_<id>.p8` file at runtime.
554554- The workflow decodes `MACOS_PROVISIONING_PROFILE`, validates it with `security cms`, and passes it
555555 to the desktop packager.
556- In-app browser passkeys depend on the same profile. The packager adds each entitlement only when
557 the profile grants it, because macOS will not launch an app that claims more than its profile
558 allows. The build log reports which ones it enabled.
559 - `keychain-access-groups` (`<TEAM_ID>.com.t3tools.t3code.webauthn`) enables Touch ID passkeys.
560 Profiles that grant the team's keychain groups (`<TEAM_ID>.*`) cover it.
561 - `com.apple.developer.web-browser.public-key-credential` lets the system passkey sheet (iCloud
562 Keychain, password managers, phones, security keys) serve any site. Apple grants it as a
563 managed capability: the Account Holder requests it through the
564 [macOS Browsers Passkeys form](https://developer.apple.com/contact/request/macos-browsers-passkeys/).
565 After approval, regenerate the profile and update `MACOS_PROVISIONING_PROFILE`.
556566
557567## 3) Azure Trusted Signing setup (Windows)
558568
pnpm-lock.yaml+136
......@@ -27,6 +27,12 @@ catalogs:
2727 '@pierre/diffs':
2828 specifier: 1.5.2
2929 version: 1.5.2
30 '@types/plist':
31 specifier: 3.0.5
32 version: 3.0.5
33 electron-webauthn:
34 specifier: 1.3.1
35 version: 1.3.1
3036 jose:
3137 specifier: 6.2.2
3238 version: 6.2.2
......@@ -42,6 +48,9 @@ catalogs:
4248 micromark-util-types:
4349 specifier: 2.0.2
4450 version: 2.0.2
51 plist:
52 specifier: 3.1.1
53 version: 3.1.1
4554 rehype-raw:
4655 specifier: 7.0.0
4756 version: 7.0.0
......@@ -60,6 +69,9 @@ catalogs:
6069 remark-rehype:
6170 specifier: 11.1.2
6271 version: 11.1.2
72 tldts:
73 specifier: 7.4.2
74 version: 7.4.2
6375 typescript:
6476 specifier: 7.0.2
6577 version: 7.0.2
......@@ -117,6 +129,8 @@ overrides:
117129 '@opencode/protocol>effect': 4.0.2
118130 '@opencode/schema>effect': 4.0.2
119131 node-abi: 4.33.0
132 electron-webauthn>@electron-webauthn/macos: 1.3.1
133 '@electron-webauthn/macos>objc-js': 1.5.0
120134 lightningcss: 1.33.0
121135 tailwindcss: 4.3.3
122136 '@types/ws@^8': 8.18.2
......@@ -229,6 +243,9 @@ importers:
229243 electron-updater:
230244 specifier: ^6.8.9
231245 version: 6.8.9
246 electron-webauthn:
247 specifier: 'catalog:'
248 version: 1.3.1(typescript@7.0.2)
232249 ffi-rs:
233250 specifier: 1.3.2
234251 version: 1.3.2
......@@ -238,6 +255,9 @@ importers:
238255 react-grab:
239256 specifier: ^0.1.32
240257 version: 0.1.44(react@19.2.6)
258 tldts:
259 specifier: 'catalog:'
260 version: 7.4.2
241261 devDependencies:
242262 '@effect/vitest':
243263 specifier: 4.0.2
......@@ -1554,6 +1574,9 @@ importers:
15541574 effect:
15551575 specifier: 4.0.2
15561576 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
1577 plist:
1578 specifier: 'catalog:'
1579 version: 3.1.1
15571580 pngjs:
15581581 specifier: 7.0.0
15591582 version: 7.0.0
......@@ -1564,6 +1587,9 @@ importers:
15641587 '@effect/vitest':
15651588 specifier: 4.0.2
15661589 version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1590 '@types/plist':
1591 specifier: 'catalog:'
1592 version: 3.0.5
15671593 '@types/pngjs':
15681594 specifier: 6.0.5
15691595 version: 6.0.5
......@@ -2868,6 +2894,15 @@ packages:
28682894 resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==}
28692895 engines: {node: '>=22.12.0'}
28702896
2897 '@electron-webauthn/macos@1.3.1':
2898 resolution: {integrity: sha512-NJA4I83ulh3pOfkv7sBkEGecTxaCtjFUbGTAi+BLNWdFc463AY7DaUprsqbGr+WAnD8SIUpuHSO7vC0C+HPu6g==}
2899 os: [darwin]
2900 peerDependencies:
2901 typescript: ^6.0.2
2902
2903 '@electron-webauthn/types@1.3.1':
2904 resolution: {integrity: sha512-C7Jy6dg32/QzIAl1E110acjpp6+b67xqZJL4L0rL2X2VIOJx4m+vfcWl3AjC4JgkxUBcSMSNa85bay2/473y/Q==}
2905
28712906 '@electron/asar@3.4.1':
28722907 resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==}
28732908 engines: {node: '>=10.12.0'}
......@@ -4215,9 +4250,32 @@ packages:
42154250 '@opencode/schema@2.0.23':
42164251 resolution: {integrity: sha512-DglA+CCNmCaiAJ8FqAKo2EKIkCOF9/EmMEYowcXhTrQcoJ7eaTx7ytGjPgis6VHQuU4aaAgwZYw96ypS3ojsIw==}
42174252
4253 '@oslojs/asn1@1.0.0':
4254 resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==}
4255 deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
4256
4257 '@oslojs/binary@1.0.0':
4258 resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==}
4259 deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
4260
4261 '@oslojs/cbor@1.0.0':
4262 resolution: {integrity: sha512-AY6Lknexs7n2xp8Cgey95c+975VG7XOk4UEdRdNFxHmDDbuf47OC/LAVRsl14DeTLwo8W6xr3HLFwUFmKcndTQ==}
4263 deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
4264
4265 '@oslojs/crypto@1.0.0':
4266 resolution: {integrity: sha512-dVz8TkkgYdr3tlwxHd7SCYGxoN7ynwHLA0nei/Aq9C+ERU0BK+U8+/3soEzBUxUNKYBf42351DyJUZ2REla50w==}
4267 deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
4268
4269 '@oslojs/encoding@1.0.0':
4270 resolution: {integrity: sha512-dyIB0SdZgMm5BhGwdSp8rMxEFIopLKxDG1vxIBaiogyom6ZqH2aXPb6DEC2WzOOWKdPSq1cxdNeRx2wAn1Z+ZQ==}
4271
42184272 '@oslojs/encoding@1.1.0':
42194273 resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
42204274
4275 '@oslojs/webauthn@1.0.0':
4276 resolution: {integrity: sha512-2ZRpbt3msNURwvjmavzq9vrNlxUnWFBGMYqbC1kO3fYBLskL7r4DiLJT1wbtLoI+hclFwjhl48YhRFBl6RWg1A==}
4277 deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
4278
42214279 '@oxc-parser/binding-android-arm-eabi@0.147.0':
42224280 resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==}
42234281 engines: {node: ^20.19.0 || >=22.12.0}
......@@ -6049,6 +6107,9 @@ packages:
60496107 '@types/node@24.12.4':
60506108 resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
60516109
6110 '@types/plist@3.0.5':
6111 resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
6112
60526113 '@types/pngjs@6.0.5':
60536114 resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
60546115
......@@ -7991,6 +8052,11 @@ packages:
79918052 electron-updater@6.8.9:
79928053 resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}
79938054
8055 electron-webauthn@1.3.1:
8056 resolution: {integrity: sha512-I8/SOjPfnIiVjN38eTX3BRZfuFDGzLWtlByh4OecmlVkfxQt9R/zCwEm4PquyvaqP+xtGgkFrBxwOuJ+DhWlDg==}
8057 peerDependencies:
8058 typescript: ^6.0.2
8059
79948060 electron-winstaller@5.4.0:
79958061 resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
79968062 engines: {node: '>=8.0.0'}
......@@ -9813,6 +9879,10 @@ packages:
98139879 node-addon-api@7.1.1:
98149880 resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
98159881
9882 node-addon-api@8.9.2:
9883 resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==}
9884 engines: {node: ^18 || ^20 || >= 21}
9885
98169886 node-api-version@0.2.1:
98179887 resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
98189888
......@@ -9908,6 +9978,10 @@ packages:
99089978 resolution: {integrity: sha512-i8iA8uij0g1YQzS8uOJPSRCgwDjO9warIHUAu1Fqj877Wc3wlfxDYBioYWgKTBF2+URVJttyDWSEpmd99nlvtQ==}
99099979 engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0}
99109980
9981 objc-js@1.5.0:
9982 resolution: {integrity: sha512-IHmouX5xYrE2UyGRRlpwY7u2HJVjF6P/P+jy6Vqga+Ya7RAf/hFeNkt4KgTkGUMM3VmnBVUEMJ0tKWY2RlYgKg==}
9983 os: [darwin]
9984
99119985 object-assign@4.1.1:
99129986 resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
99139987 engines: {node: '>=0.10.0'}
......@@ -13599,6 +13673,16 @@ snapshots:
1359913673
1360013674 '@electron-internal/extract-zip@1.0.5': {}
1360113675
13676 '@electron-webauthn/macos@1.3.1(typescript@7.0.2)':
13677 dependencies:
13678 '@electron-webauthn/types': 1.3.1
13679 '@oslojs/webauthn': 1.0.0
13680 objc-js: 1.5.0
13681 typescript: 7.0.2
13682 optional: true
13683
13684 '@electron-webauthn/types@1.3.1': {}
13685
1360213686 '@electron/asar@3.4.1':
1360313687 dependencies:
1360413688 commander: 5.1.0
......@@ -14991,8 +15075,39 @@ snapshots:
1499115075 '@standard-schema/spec': 1.1.0
1499215076 effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
1499315077
15078 '@oslojs/asn1@1.0.0':
15079 dependencies:
15080 '@oslojs/binary': 1.0.0
15081 optional: true
15082
15083 '@oslojs/binary@1.0.0':
15084 optional: true
15085
15086 '@oslojs/cbor@1.0.0':
15087 dependencies:
15088 '@oslojs/binary': 1.0.0
15089 optional: true
15090
15091 '@oslojs/crypto@1.0.0':
15092 dependencies:
15093 '@oslojs/asn1': 1.0.0
15094 '@oslojs/binary': 1.0.0
15095 optional: true
15096
15097 '@oslojs/encoding@1.0.0':
15098 optional: true
15099
1499415100 '@oslojs/encoding@1.1.0': {}
1499515101
15102 '@oslojs/webauthn@1.0.0':
15103 dependencies:
15104 '@oslojs/asn1': 1.0.0
15105 '@oslojs/binary': 1.0.0
15106 '@oslojs/cbor': 1.0.0
15107 '@oslojs/crypto': 1.0.0
15108 '@oslojs/encoding': 1.0.0
15109 optional: true
15110
1499615111 '@oxc-parser/binding-android-arm-eabi@0.147.0':
1499715112 optional: true
1499815113
......@@ -16589,6 +16704,11 @@ snapshots:
1658916704 dependencies:
1659016705 undici-types: 7.16.0
1659116706
16707 '@types/plist@3.0.5':
16708 dependencies:
16709 '@types/node': 24.12.4
16710 xmlbuilder: 15.1.1
16711
1659216712 '@types/pngjs@6.0.5':
1659316713 dependencies:
1659416714 '@types/node': 24.12.4
......@@ -18436,6 +18556,13 @@ snapshots:
1843618556 transitivePeerDependencies:
1843718557 - supports-color
1843818558
18559 electron-webauthn@1.3.1(typescript@7.0.2):
18560 dependencies:
18561 '@electron-webauthn/types': 1.3.1
18562 typescript: 7.0.2
18563 optionalDependencies:
18564 '@electron-webauthn/macos': 1.3.1(typescript@7.0.2)
18565
1843918566 electron-winstaller@5.4.0:
1844018567 dependencies:
1844118568 '@electron/asar': 3.4.1
......@@ -20739,6 +20866,9 @@ snapshots:
2073920866
2074020867 node-addon-api@7.1.1: {}
2074120868
20869 node-addon-api@8.9.2:
20870 optional: true
20871
2074220872 node-api-version@0.2.1:
2074320873 dependencies:
2074420874 semver: 7.8.5
......@@ -20857,6 +20987,12 @@ snapshots:
2085720987 dependencies:
2085820988 flow-enums-runtime: 0.0.6
2085920989
20990 objc-js@1.5.0:
20991 dependencies:
20992 node-addon-api: 8.9.2
20993 node-gyp-build: 4.8.4
20994 optional: true
20995
2086020996 object-assign@4.1.1: {}
2086120997
2086220998 object-inspect@1.13.4: {}
pnpm-workspace.yaml+12
......@@ -18,6 +18,8 @@ allowBuilds:
1818 msgpackr-extract: true
1919 msw: false
2020 node-pty: true
21 # Ships N-API prebuilds; its install script only rebuilds from source.
22 objc-js: false
2123 sharp: true
2224 utf-8-validate: false
2325 workerd: false
......@@ -38,6 +40,7 @@ catalog:
3840 "@effect/sql-sqlite-do": 4.0.2
3941 "@effect/tsgo": 0.46.1
4042 "@effect/vitest": 4.0.2
43 "@electron-webauthn/macos": 1.3.1
4144 "@legendapp/list": 3.3.5
4245 "@noble/curves": 1.9.1
4346 "@noble/hashes": 1.8.0
......@@ -46,11 +49,16 @@ catalog:
4649 "@tailwindcss/oxide": 4.3.3
4750 "@tailwindcss/vite": 4.3.3
4851 "@types/node": 24.12.4
52 "@types/plist": 3.0.5
4953 "@types/ws": 8.18.2
5054 effect: 4.0.2
55 electron-webauthn: 1.3.1
5156 jose: 6.2.2
5257 lightningcss: 1.33.0
58 objc-js: 1.5.0
59 plist: 3.1.1
5360 tailwindcss: 4.3.3
61 tldts: 7.4.2
5462 typescript: 7.0.2
5563 undici: 8.11.2
5664 vite: npm:@voidzero-dev/vite-plus-core@1.0.0
......@@ -249,6 +257,10 @@ overrides:
249257 "@opencode/protocol>effect": "catalog:"
250258 "@opencode/schema>effect": "catalog:"
251259 node-abi: 4.33.0
260 # The desktop app installs these without a lockfile when it packages them, so
261 # pin the native passkey code that runs in its main process.
262 "electron-webauthn>@electron-webauthn/macos": "catalog:"
263 "@electron-webauthn/macos>objc-js": "catalog:"
252264 lightningcss: "catalog:"
253265 tailwindcss: "catalog:"
254266 # Ranges from @effect/platform-node, jsdom, unifont, Expo and libsql otherwise
scripts/build-desktop-artifact.test.ts+90-1
......@@ -44,6 +44,7 @@ import {
4444 preflightMacDesktopBuild,
4545 preflightWindowsDesktopBuild,
4646 renderMacPasskeyEntitlements,
47 resolveMacWebAuthnEntitlements,
4748 resolveClerkPasskeyNativeArtifacts,
4849 resolveMacPasskeySigningConfiguration,
4950 resolveDesktopRuntimeDependencies,
......@@ -95,6 +96,12 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
9596import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
9697import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
9798
99// Keeps pnpm from auto-installing TypeScript, a types-only peer, into the app.
100const stagePackageExtensions = {
101 "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } },
102 "@electron-webauthn/macos": { peerDependenciesMeta: { typescript: { optional: true } } },
103};
104
98105// A minimal stand-in for the Linux CLI release archive: one top-level
99106// directory named after the archive stem holding the executable, the web
100107// client, and the runtime externals with node-pty built from source.
......@@ -454,6 +461,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
454461 os: ["darwin"],
455462 cpu: ["x64"],
456463 },
464 packageExtensions: stagePackageExtensions,
457465 });
458466 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "linux", arch: "x64" }), {
459467 supportedArchitectures: {
......@@ -461,6 +469,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
461469 cpu: ["x64"],
462470 libc: ["glibc"],
463471 },
472 packageExtensions: stagePackageExtensions,
464473 });
465474 // Windows stages only win32 natives; WSL runs the separately built Linux
466475 // CLI archive rather than anything installed here.
......@@ -469,18 +478,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
469478 os: ["win32"],
470479 cpu: ["x64"],
471480 },
481 packageExtensions: stagePackageExtensions,
472482 });
473483 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), {
474484 supportedArchitectures: {
475485 os: ["win32"],
476486 cpu: ["arm64"],
477487 },
488 packageExtensions: stagePackageExtensions,
478489 });
479490 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), {
480491 supportedArchitectures: {
481492 os: ["darwin"],
482493 cpu: ["arm64", "x64"],
483494 },
495 packageExtensions: stagePackageExtensions,
484496 });
485497 });
486498
......@@ -507,6 +519,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
507519 cpu: ["x64"],
508520 libc: ["glibc"],
509521 },
522 packageExtensions: stagePackageExtensions,
510523 allowBuilds: {
511524 electron: true,
512525 "node-pty": true,
......@@ -537,6 +550,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
537550 os: ["darwin"],
538551 cpu: ["arm64"],
539552 },
553 packageExtensions: stagePackageExtensions,
540554 },
541555 );
542556 });
......@@ -1886,7 +1900,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
18861900 T3CODE_CLERK_PASSKEY_RP_DOMAINS:
18871901 " Clerk.Example.com,example.clerk.accounts.dev,clerk.example.com ",
18881902 });
1889 const entitlements = renderMacPasskeyEntitlements(configuration);
1903 const entitlements = renderMacPasskeyEntitlements(configuration, {
1904 touchIdKeychainAccessGroup: undefined,
1905 browserPasskeys: false,
1906 });
18901907
18911908 assert.deepStrictEqual(configuration.rpDomains, [
18921909 "clerk.example.com",
......@@ -1896,6 +1913,78 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
18961913 assert.include(entitlements, "<string>webcredentials:clerk.example.com</string>");
18971914 assert.include(entitlements, "<string>webcredentials:example.clerk.accounts.dev</string>");
18981915 assert.include(entitlements, "<key>com.apple.security.cs.allow-jit</key>");
1916 assert.notInclude(entitlements, "keychain-access-groups");
1917 assert.notInclude(entitlements, "com.apple.developer.web-browser.public-key-credential");
1918 });
1919
1920 it("grants in-app browser passkey entitlements only when the provisioning profile does", () => {
1921 const configuration = { appId: "com.t3tools.t3code", teamId: "ABC1234567" };
1922 // Profiles are CMS envelopes around a plain XML plist.
1923 const profile = (entitlements: string, outside = "") =>
1924 `0\x82\x1f\x9a\x06\t*\x86H<?xml version="1.0"?><plist version="1.0"><dict>${outside}<key>Entitlements</key><dict>${entitlements}</dict></dict></plist>\x00\x01`;
1925 const teamWildcardGroups = `<key>keychain-access-groups</key>
1926 <array>
1927 <string>ABC1234567.*</string>
1928 <string>com.apple.token</string>
1929 </array>`;
1930
1931 assert.deepStrictEqual(resolveMacWebAuthnEntitlements(profile(""), configuration), {
1932 touchIdKeychainAccessGroup: undefined,
1933 browserPasskeys: false,
1934 });
1935 assert.deepStrictEqual(
1936 resolveMacWebAuthnEntitlements(
1937 profile("<key>keychain-access-groups</key><array><string>OTHERTEAM1.*</string></array>"),
1938 configuration,
1939 ),
1940 { touchIdKeychainAccessGroup: undefined, browserPasskeys: false },
1941 );
1942 // Only real values inside the Entitlements dict count: not comments, not
1943 // explicit false, not keys elsewhere in the profile.
1944 assert.deepStrictEqual(
1945 resolveMacWebAuthnEntitlements(
1946 profile(
1947 `<!-- ${teamWildcardGroups}
1948 <key>com.apple.developer.web-browser.public-key-credential</key><true/> -->
1949 <key>com.apple.developer.web-browser.public-key-credential</key><false/>`,
1950 teamWildcardGroups,
1951 ),
1952 configuration,
1953 ),
1954 { touchIdKeychainAccessGroup: undefined, browserPasskeys: false },
1955 );
1956 assert.deepStrictEqual(
1957 resolveMacWebAuthnEntitlements(
1958 profile(
1959 `${teamWildcardGroups}
1960 <key>com.apple.developer.web-browser.public-key-credential</key>
1961 <true/>`,
1962 ),
1963 configuration,
1964 ),
1965 {
1966 touchIdKeychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn",
1967 browserPasskeys: true,
1968 },
1969 );
1970
1971 const entitlements = renderMacPasskeyEntitlements(
1972 { ...configuration, rpDomains: ["clerk.example.com"], provisioningProfilePath: "" },
1973 resolveMacWebAuthnEntitlements(
1974 profile(
1975 `${teamWildcardGroups}<key>com.apple.developer.web-browser.public-key-credential</key><true/>`,
1976 ),
1977 configuration,
1978 ),
1979 );
1980 assert.match(
1981 entitlements,
1982 /<key>keychain-access-groups<\/key>\s*<array>\s*<string>ABC1234567\.com\.t3tools\.t3code\.webauthn<\/string>\s*<\/array>/u,
1983 );
1984 assert.match(
1985 entitlements,
1986 /<key>com\.apple\.developer\.web-browser\.public-key-credential<\/key>\s*<true\/>/u,
1987 );
18991988 });
19001989
19011990 it("rejects incomplete macOS passkey signing configuration", () => {
scripts/build-desktop-artifact.ts+105-2
......@@ -4,6 +4,8 @@
44import * as NodeFSP from "node:fs/promises";
55import * as NodeCrypto from "node:crypto";
66import * as NodeModule from "node:module";
7// plist is CommonJS; Node cannot load its named exports from an ES module.
8import Plist from "plist";
79
810import {
911 createPackageWithOptions,
......@@ -80,10 +82,28 @@ const StageWorkspaceConfig = Schema.Struct({
8082 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
8183 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
8284 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),
85 packageExtensions: Schema.optional(
86 Schema.Record(
87 Schema.String,
88 Schema.Struct({
89 peerDependenciesMeta: Schema.Record(
90 Schema.String,
91 Schema.Struct({ optional: Schema.Boolean }),
92 ),
93 }),
94 ),
95 ),
8396 nodeLinker: Schema.optional(Schema.Literals(["hoisted"])),
8497});
8598type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type;
8699
100// electron-webauthn declares TypeScript as a peer only for its typings. pnpm
101// auto-installs missing peers, which would ship a compiler inside the app.
102const STAGE_PACKAGE_EXTENSIONS = {
103 "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } },
104 "@electron-webauthn/macos": { peerDependenciesMeta: { typescript: { optional: true } } },
105} as const;
106
87107const RepoRoot = Effect.service(Path.Path).pipe(
88108 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
89109);
......@@ -926,6 +946,7 @@ interface StagePackageJson {
926946 readonly version: string;
927947 readonly buildVersion: string;
928948 readonly t3codeCommitHash: string;
949 readonly t3codeWebAuthn?: MacWebAuthnEntitlements;
929950 readonly private: true;
930951 readonly packageManager: string;
931952 readonly description: string;
......@@ -1289,12 +1310,80 @@ function escapeXml(value: string): string {
12891310 .replaceAll("'", "&apos;");
12901311}
12911312
1313/**
1314 * Passkey entitlements for the in-app browser. Each is granted only when the
1315 * provisioning profile authorizes it: macOS refuses to launch an app that
1316 * claims a restricted entitlement its embedded profile does not carry.
1317 */
1318export interface MacWebAuthnEntitlements {
1319 /** Keychain group for Electron's Touch ID passkeys. */
1320 readonly touchIdKeychainAccessGroup: string | undefined;
1321 /** Apple's managed browser entitlement, which allows passkeys for any site. */
1322 readonly browserPasskeys: boolean;
1323}
1324
1325const BROWSER_PASSKEYS_ENTITLEMENT = "com.apple.developer.web-browser.public-key-credential";
1326
1327const ProvisioningProfilePlist = Schema.Struct({
1328 Entitlements: Schema.Struct({
1329 "keychain-access-groups": Schema.optional(Schema.Array(Schema.String)),
1330 [BROWSER_PASSKEYS_ENTITLEMENT]: Schema.optional(Schema.Boolean),
1331 }),
1332});
1333const isProvisioningProfilePlist = Schema.is(ProvisioningProfilePlist);
1334
1335/**
1336 * Reads the Entitlements dict of the XML plist a provisioning profile wraps in
1337 * its CMS envelope. Anything unreadable grants nothing.
1338 */
1339const readProfileEntitlements = (provisioningProfile: string) => {
1340 const start = provisioningProfile.indexOf("<?xml");
1341 const end = provisioningProfile.indexOf("</plist>", start);
1342 if (start === -1 || end === -1) return undefined;
1343 try {
1344 const profile: unknown = Plist.parse(provisioningProfile.slice(start, end + "</plist>".length));
1345 return isProvisioningProfilePlist(profile) ? profile.Entitlements : undefined;
1346 } catch {
1347 return undefined;
1348 }
1349};
1350
1351export function resolveMacWebAuthnEntitlements(
1352 provisioningProfile: string,
1353 configuration: Pick<MacPasskeySigningConfiguration, "appId" | "teamId">,
1354): MacWebAuthnEntitlements {
1355 const entitlements = readProfileEntitlements(provisioningProfile);
1356 const keychainAccessGroup = `${configuration.teamId}.${configuration.appId}.webauthn`;
1357 const keychainGroupAuthorized = (entitlements?.["keychain-access-groups"] ?? []).some((group) =>
1358 group.endsWith("*")
1359 ? keychainAccessGroup.startsWith(group.slice(0, -1))
1360 : group === keychainAccessGroup,
1361 );
1362 return {
1363 touchIdKeychainAccessGroup: keychainGroupAuthorized ? keychainAccessGroup : undefined,
1364 browserPasskeys: entitlements?.[BROWSER_PASSKEYS_ENTITLEMENT] === true,
1365 };
1366}
1367
12921368export function renderMacPasskeyEntitlements(
12931369 configuration: MacPasskeySigningConfiguration,
1370 webAuthn: MacWebAuthnEntitlements,
12941371): string {
12951372 const associatedDomains = configuration.rpDomains
12961373 .map((domain) => ` <string>webcredentials:${escapeXml(domain)}</string>`)
12971374 .join("\n");
1375 const keychainAccessGroups = webAuthn.touchIdKeychainAccessGroup
1376 ? `
1377 <key>keychain-access-groups</key>
1378 <array>
1379 <string>${escapeXml(webAuthn.touchIdKeychainAccessGroup)}</string>
1380 </array>`
1381 : "";
1382 const browserPasskeys = webAuthn.browserPasskeys
1383 ? `
1384 <key>${BROWSER_PASSKEYS_ENTITLEMENT}</key>
1385 <true/>`
1386 : "";
12981387
12991388 return `<?xml version="1.0" encoding="UTF-8"?>
13001389<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
......@@ -1307,7 +1396,7 @@ export function renderMacPasskeyEntitlements(
13071396 <key>com.apple.developer.associated-domains</key>
13081397 <array>
13091398${associatedDomains}
1310 </array>
1399 </array>${keychainAccessGroups}${browserPasskeys}
13111400 <key>com.apple.security.cs.allow-jit</key>
13121401 <true/>
13131402 <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
......@@ -1535,6 +1624,7 @@ export function createStageWorkspaceConfig(input: {
15351624 ? { patchedDependencies }
15361625 : {}),
15371626 ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),
1627 packageExtensions: STAGE_PACKAGE_EXTENSIONS,
15381628 };
15391629}
15401630
......@@ -3673,13 +3763,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
36733763 const macEntitlementsPath = macPasskeySigning
36743764 ? path.join(stageAppDir, "entitlements.mac.plist")
36753765 : undefined;
3766 let macWebAuthn: MacWebAuthnEntitlements | undefined;
36763767 if (macPasskeySigning && macEntitlementsPath) {
36773768 if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) {
36783769 return yield* new MacProvisioningProfileNotFoundError({
36793770 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
36803771 });
36813772 }
3682 yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning));
3773 macWebAuthn = resolveMacWebAuthnEntitlements(
3774 yield* fs.readFileString(macPasskeySigning.provisioningProfilePath),
3775 macPasskeySigning,
3776 );
3777 yield* Effect.log(
3778 `[desktop-artifact] In-app browser passkeys: Touch ID ${macWebAuthn.touchIdKeychainAccessGroup ? "enabled" : "disabled"}, browser passkeys ${macWebAuthn.browserPasskeys ? "enabled" : "disabled"}.`,
3779 );
3780 yield* fs.writeFileString(
3781 macEntitlementsPath,
3782 renderMacPasskeyEntitlements(macPasskeySigning, macWebAuthn),
3783 );
36833784 }
36843785
36853786 // Windows splits dependencies per process: app.asar carries only the
......@@ -3709,6 +3810,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
37093810 version: appVersion,
37103811 buildVersion: appVersion,
37113812 t3codeCommitHash: commitHash,
3813 // Read by apps/desktop/src/preview/Passkeys.ts; must match the signed entitlements.
3814 ...(macWebAuthn ? { t3codeWebAuthn: macWebAuthn } : {}),
37123815 private: true,
37133816 packageManager: rootPackageJson.packageManager,
37143817 description:
scripts/lib/desktop-external-packages.ts+1
......@@ -16,6 +16,7 @@ export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [
1616 "@napi-rs/keyring",
1717 "@crowecawcaw/xa11y",
1818 "@clerk/electron-passkeys",
19 "electron-webauthn",
1920 "ffi-rs",
2021 "@yuuang/",
2122 // Reads its own bundle from disk by resolving `playwright-core/package.json`
scripts/package.json+2
......@@ -13,11 +13,13 @@
1313 "@t3tools/shared": "workspace:*",
1414 "@t3tools/tailscale": "workspace:*",
1515 "effect": "catalog:",
16 "plist": "catalog:",
1617 "pngjs": "7.0.0",
1718 "sharp": "0.35.4"
1819 },
1920 "devDependencies": {
2021 "@effect/vitest": "catalog:",
22 "@types/plist": "catalog:",
2123 "@types/pngjs": "6.0.5",
2224 "typescript": "catalog:",
2325 "typescript-legacy": "npm:typescript@~6.0.3",
third-party-licenses.config.json+21
......@@ -278,6 +278,27 @@
278278 "copyrights": ["Copyright (c) 2025 Stephen Crowe"]
279279 }
280280 },
281 {
282 "license": "MIT",
283 "repositoryUrl": "https://github.com/iamEvanYT/electron-webauthn",
284 "generatedNotice": {
285 "licenseId": "MIT",
286 "copyrights": ["Copyright (c) 2026 iamEvan"]
287 }
288 },
289 {
290 "license": "MIT",
291 "name": "@electron-webauthn/types",
292 "sourceUrl": "https://github.com/iamEvanYT/electron-webauthn",
293 "generatedNotice": {
294 "licenseId": "MIT",
295 "copyrights": ["Copyright (c) 2026 iamEvan"]
296 }
297 },
298 {
299 "license": "MIT",
300 "name": "objc-js"
301 },
281302 {
282303 "license": "MIT",
283304 "name": "map-stream",