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 @@...@@ -27,9 +27,11 @@
27 "electron": "44.4.5",27 "electron": "44.4.5",
28 "electron-store": "^8.2.0",28 "electron-store": "^8.2.0",
29 "electron-updater": "^6.8.9",29 "electron-updater": "^6.8.9",
30 "electron-webauthn": "catalog:",
30 "ffi-rs": "1.3.2",31 "ffi-rs": "1.3.2",
31 "playwright-core": "1.60.0",32 "playwright-core": "1.60.0",
32 "react-grab": "^0.1.32"33 "react-grab": "^0.1.32",
34 "tldts": "catalog:"
33 },35 },
34 "devDependencies": {36 "devDependencies": {
35 "@effect/vitest": "catalog:",37 "@effect/vitest": "catalog:",
apps/desktop/src/app/DesktopApp.ts+3
...@@ -17,6 +17,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";...@@ -17,6 +17,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts";
17import * as DesktopClerk from "./DesktopClerk.ts";17import * as DesktopClerk from "./DesktopClerk.ts";
18import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";18import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts";
19import * as DesktopWindow from "../window/DesktopWindow.ts";19import * as DesktopWindow from "../window/DesktopWindow.ts";
20import * as PreviewPasskeys from "../preview/Passkeys.ts";
20import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";21import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts";
21import * as DesktopEnvironment from "./DesktopEnvironment.ts";22import * as DesktopEnvironment from "./DesktopEnvironment.ts";
22import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";23import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts";
...@@ -276,6 +277,7 @@ const startup = Effect.gen(function* () {...@@ -276,6 +277,7 @@ const startup = Effect.gen(function* () {
276 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;277 const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage;
277 const updates = yield* DesktopUpdates.DesktopUpdates;278 const updates = yield* DesktopUpdates.DesktopUpdates;
278 const environment = yield* DesktopEnvironment.DesktopEnvironment;279 const environment = yield* DesktopEnvironment.DesktopEnvironment;
280 const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys;
279281
280 yield* shellEnvironment.installIntoProcess;282 yield* shellEnvironment.installIntoProcess;
281 const hasCommandLinePasswordStore =283 const hasCommandLinePasswordStore =
...@@ -329,6 +331,7 @@ const startup = Effect.gen(function* () {...@@ -329,6 +331,7 @@ const startup = Effect.gen(function* () {
329 });331 });
330 }332 }
331 yield* appIdentity.configure;333 yield* appIdentity.configure;
334 yield* previewPasskeys.configure;
332 yield* applicationMenu.configure;335 yield* applicationMenu.configure;
333 yield* updates.configure;336 yield* updates.configure;
334 yield* DesktopRemoteUpdates.listen;337 yield* DesktopRemoteUpdates.listen;
apps/desktop/src/main.ts+2
...@@ -70,6 +70,7 @@ import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret....@@ -70,6 +70,7 @@ import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.
70import * as BrowserSession from "./preview/BrowserSession.ts";70import * as BrowserSession from "./preview/BrowserSession.ts";
71import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts";71import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts";
72import * as PreviewManager from "./preview/Manager.ts";72import * as PreviewManager from "./preview/Manager.ts";
73import * as PreviewPasskeys from "./preview/Passkeys.ts";
73import * as DesktopWindow from "./window/DesktopWindow.ts";74import * as DesktopWindow from "./window/DesktopWindow.ts";
74import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";75import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts";
75import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";76import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts";
...@@ -168,6 +169,7 @@ const layerDesktopPreview = PreviewManager.layer.pipe(...@@ -168,6 +169,7 @@ const layerDesktopPreview = PreviewManager.layer.pipe(
168 // service alongside the manager; both sit on the same BrowserSession.169 // service alongside the manager; both sit on the same BrowserSession.
169 Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),170 Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))),
170 Layer.provideMerge(BrowserSession.layer),171 Layer.provideMerge(BrowserSession.layer),
172 Layer.provideMerge(PreviewPasskeys.layer),
171 Layer.provideMerge(layerDesktopFoundation),173 Layer.provideMerge(layerDesktopFoundation),
172);174);
173175
apps/desktop/src/preview-pick-preload.ts+9
...@@ -1 +1,10 @@...@@ -1 +1,10 @@
1import { ipcRenderer } from "electron";
2
3import { PASSKEY_BRIDGE_ARGUMENT } from "./preview/GuestProtocol.ts";
4import { installPasskeyBridge } from "./preview/PasskeyBridge.ts";
1import "./preview/PickPreload.ts";5import "./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";...@@ -11,3 +11,7 @@ export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer";
11export const RECORDING_KEY_CHANNEL = "preview:recording-key";11export const RECORDING_KEY_CHANNEL = "preview:recording-key";
12export const RECORDING_INPUT_CHANNEL = "preview:recording-input";12export const RECORDING_INPUT_CHANNEL = "preview:recording-input";
13export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller";13export 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";...@@ -29,6 +29,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts";
29import * as BrowserSession from "./BrowserSession.ts";29import * as BrowserSession from "./BrowserSession.ts";
30import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";30import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
31import * as PreviewManager from "./Manager.ts";31import * as PreviewManager from "./Manager.ts";
32import * as PreviewPasskeys from "./Passkeys.ts";
3233
33describe("fitPictureInPictureContentSize", () => {34describe("fitPictureInPictureContentSize", () => {
34 it("preserves the PiP content area across aspect-ratio changes", () => {35 it("preserves the PiP content area across aspect-ratio changes", () => {
...@@ -301,6 +302,13 @@ const managerLayer = (platform: NodeJS.Platform = "darwin") =>...@@ -301,6 +302,13 @@ const managerLayer = (platform: NodeJS.Platform = "darwin") =>
301 ),302 ),
302 Layer.provideMerge(DesktopBrowserHost.layer),303 Layer.provideMerge(DesktopBrowserHost.layer),
303 Layer.provideMerge(layerBrowserSession),304 Layer.provideMerge(layerBrowserSession),
305 Layer.provideMerge(
306 Layer.mock(PreviewPasskeys.PreviewPasskeys)({
307 bridgeEnabled: false,
308 installSessionHandlers: () => {},
309 attachGuest: () => () => {},
310 }),
311 ),
304 Layer.provideMerge(layerEnvironment),312 Layer.provideMerge(layerEnvironment),
305 Layer.provideMerge(layerFileSystem),313 Layer.provideMerge(layerFileSystem),
306 Layer.provideMerge(Path.layer),314 Layer.provideMerge(Path.layer),
apps/desktop/src/preview/Manager.ts+7
...@@ -64,6 +64,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";...@@ -64,6 +64,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
64import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";64import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
65import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";65import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts";
66import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";66import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
67import * as PreviewPasskeys from "./Passkeys.ts";
67import * as BrowserSession from "./BrowserSession.ts";68import * as BrowserSession from "./BrowserSession.ts";
68import {69import {
69 ANNOTATION_CAPTURED_CHANNEL,70 ANNOTATION_CAPTURED_CHANNEL,
...@@ -548,6 +549,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -548,6 +549,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
548 const crypto = yield* Crypto.Crypto;549 const crypto = yield* Crypto.Crypto;
549 const parentScope = yield* Scope.Scope;550 const parentScope = yield* Scope.Scope;
550 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;551 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
552 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
551 const context = yield* Effect.context<never>();553 const context = yield* Effect.context<never>();
552 const runFork = Effect.runForkWith(context);554 const runFork = Effect.runForkWith(context);
553 const resolvedArtifactDirectory = path.resolve(artifactDirectory);555 const resolvedArtifactDirectory = path.resolve(artifactDirectory);
...@@ -1254,6 +1256,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1254,6 +1256,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1254 ) {1256 ) {
1255 const scope = yield* Scope.fork(parentScope, "sequential");1257 const scope = yield* Scope.fork(parentScope, "sequential");
1256 const attachmentId = Symbol();1258 const attachmentId = Symbol();
1259 let detachPasskeys = () => {};
1257 let documentId = 0;1260 let documentId = 0;
1258 let nextRequestId = 0;1261 let nextRequestId = 0;
1259 let activeCapture: {1262 let activeCapture: {
...@@ -1589,6 +1592,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1589,6 +1592,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1589 wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);1592 wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput);
1590 wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput);1593 wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput);
1591 wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);1594 wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
1595 detachPasskeys();
1592 }).pipe(Effect.ignore),1596 }).pipe(Effect.ignore),
1593 );1597 );
1594 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {1598 const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () {
...@@ -1609,6 +1613,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1609,6 +1613,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1609 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);1613 wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput);
1610 wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput);1614 wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput);
1611 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);1615 wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate);
1616 detachPasskeys = passkeys.attachGuest(wc);
1612 wc.setWindowOpenHandler((details) => {1617 wc.setWindowOpenHandler((details) => {
1613 const action = previewWindowOpenAction(details);1618 const action = previewWindowOpenAction(details);
1614 if (action === "popup") {1619 if (action === "popup") {
...@@ -3686,6 +3691,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3686,6 +3691,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3686 const environment = yield* DesktopEnvironment.DesktopEnvironment;3691 const environment = yield* DesktopEnvironment.DesktopEnvironment;
3687 const browserSession = yield* BrowserSession.BrowserSession;3692 const browserSession = yield* BrowserSession.BrowserSession;
3688 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;3693 const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost;
3694 const passkeys = yield* PreviewPasskeys.PreviewPasskeys;
3689 const downloadSessions = new WeakSet<Electron.Session>();3695 const downloadSessions = new WeakSet<Electron.Session>();
3690 const fileSystem = yield* FileSystem.FileSystem;3696 const fileSystem = yield* FileSystem.FileSystem;
3691 const path = yield* Path.Path;3697 const path = yield* Path.Path;
...@@ -3750,6 +3756,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {...@@ -3750,6 +3756,7 @@ export const make = Effect.gen(function* PreviewManagerMake() {
3750 ),3756 ),
3751 );3757 );
3752 placeServerDownloads(session);3758 placeServerDownloads(session);
3759 passkeys.installSessionHandlers(session);
3753 return session;3760 return session;
3754 },3761 },
3755 ),3762 ),
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 {...@@ -56,6 +56,7 @@ import {
56import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";56import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts";
57import * as DesktopWindow from "./DesktopWindow.ts";57import * as DesktopWindow from "./DesktopWindow.ts";
58import * as PreviewManager from "../preview/Manager.ts";58import * as PreviewManager from "../preview/Manager.ts";
59import * as PreviewPasskeys from "../preview/Passkeys.ts";
5960
60const environmentInput = {61const environmentInput = {
61 dirname: "/repo/apps/desktop/dist-electron",62 dirname: "/repo/apps/desktop/dist-electron",
...@@ -325,6 +326,11 @@ function layerTest(input: {...@@ -325,6 +326,11 @@ function layerTest(input: {
325 } satisfies ElectronShell.ElectronShell["Service"]),326 } satisfies ElectronShell.ElectronShell["Service"]),
326 layerElectronTheme,327 layerElectronTheme,
327 layerElectronWindow,328 layerElectronWindow,
329 Layer.mock(PreviewPasskeys.PreviewPasskeys)({
330 bridgeEnabled: false,
331 installSessionHandlers: () => {},
332 attachGuest: () => () => {},
333 }),
328 Layer.mock(PreviewManager.PreviewManager)({334 Layer.mock(PreviewManager.PreviewManager)({
329 getBrowserSession: () => Effect.succeed({} as Electron.Session),335 getBrowserSession: () => Effect.succeed({} as Electron.Session),
330 setMainWindow: () => Effect.void,336 setMainWindow: () => Effect.void,
...@@ -433,6 +439,11 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n...@@ -433,6 +439,11 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
433 } satisfies ElectronShell.ElectronShell["Service"]),439 } satisfies ElectronShell.ElectronShell["Service"]),
434 layerElectronTheme,440 layerElectronTheme,
435 Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape),441 Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape),
442 Layer.mock(PreviewPasskeys.PreviewPasskeys)({
443 bridgeEnabled: false,
444 installSessionHandlers: () => {},
445 attachGuest: () => () => {},
446 }),
436 Layer.mock(PreviewManager.PreviewManager)({447 Layer.mock(PreviewManager.PreviewManager)({
437 getBrowserSession: () => Effect.succeed({} as Electron.Session),448 getBrowserSession: () => Effect.succeed({} as Electron.Session),
438 setMainWindow: () => Effect.void,449 setMainWindow: () => Effect.void,
apps/desktop/src/window/DesktopWindow.ts+9
...@@ -25,7 +25,9 @@ import {...@@ -25,7 +25,9 @@ import {
25 TRACKPAD_SCROLL_END_CHANNEL,25 TRACKPAD_SCROLL_END_CHANNEL,
26 WINDOW_FULLSCREEN_STATE_CHANNEL,26 WINDOW_FULLSCREEN_STATE_CHANNEL,
27} from "../ipc/channels.ts";27} from "../ipc/channels.ts";
28import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts";
28import * as PreviewManager from "../preview/Manager.ts";29import * as PreviewManager from "../preview/Manager.ts";
30import * as PreviewPasskeys from "../preview/Passkeys.ts";
29import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";31import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts";
30import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";32import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
31import * as ElectronApp from "../electron/ElectronApp.ts";33import * as ElectronApp from "../electron/ElectronApp.ts";
...@@ -326,6 +328,7 @@ export const make = Effect.gen(function* () {...@@ -326,6 +328,7 @@ export const make = Effect.gen(function* () {
326 const electronTheme = yield* ElectronTheme.ElectronTheme;328 const electronTheme = yield* ElectronTheme.ElectronTheme;
327 const electronWindow = yield* ElectronWindow.ElectronWindow;329 const electronWindow = yield* ElectronWindow.ElectronWindow;
328 const previewManager = yield* PreviewManager.PreviewManager;330 const previewManager = yield* PreviewManager.PreviewManager;
331 const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys;
329 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;332 const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings;
330 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;333 const clientSettings = yield* DesktopClientSettings.DesktopClientSettings;
331 const electronApp = yield* ElectronApp.ElectronApp;334 const electronApp = yield* ElectronApp.ElectronApp;
...@@ -537,6 +540,12 @@ export const make = Effect.gen(function* () {...@@ -537,6 +540,12 @@ export const make = Effect.gen(function* () {
537 webPreferences.nodeIntegrationInSubFrames = false;540 webPreferences.nodeIntegrationInSubFrames = false;
538 webPreferences.contextIsolation = false;541 webPreferences.contextIsolation = false;
539 webPreferences.disableHtmlFullscreenWindowResize = true;542 webPreferences.disableHtmlFullscreenWindowResize = true;
543 if (previewPasskeys.bridgeEnabled) {
544 webPreferences.additionalArguments = [
545 ...(webPreferences.additionalArguments ?? []),
546 PASSKEY_BRIDGE_ARGUMENT,
547 ];
548 }
540 });549 });
541550
542 const contextMenuContents = new WeakSet<Electron.WebContents>();551 const contextMenuContents = new WeakSet<Electron.WebContents>();
docs/operations/release.md+10
...@@ -553,6 +553,16 @@ Notes:...@@ -553,6 +553,16 @@ Notes:
553- The workflow writes it to a temporary `AuthKey_<id>.p8` file at runtime.553- The workflow writes it to a temporary `AuthKey_<id>.p8` file at runtime.
554- The workflow decodes `MACOS_PROVISIONING_PROFILE`, validates it with `security cms`, and passes it554- The workflow decodes `MACOS_PROVISIONING_PROFILE`, validates it with `security cms`, and passes it
555 to the desktop packager.555 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
557## 3) Azure Trusted Signing setup (Windows)567## 3) Azure Trusted Signing setup (Windows)
558568
pnpm-lock.yaml+136
...@@ -27,6 +27,12 @@ catalogs:...@@ -27,6 +27,12 @@ catalogs:
27 '@pierre/diffs':27 '@pierre/diffs':
28 specifier: 1.5.228 specifier: 1.5.2
29 version: 1.5.229 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
30 jose:36 jose:
31 specifier: 6.2.237 specifier: 6.2.2
32 version: 6.2.238 version: 6.2.2
...@@ -42,6 +48,9 @@ catalogs:...@@ -42,6 +48,9 @@ catalogs:
42 micromark-util-types:48 micromark-util-types:
43 specifier: 2.0.249 specifier: 2.0.2
44 version: 2.0.250 version: 2.0.2
51 plist:
52 specifier: 3.1.1
53 version: 3.1.1
45 rehype-raw:54 rehype-raw:
46 specifier: 7.0.055 specifier: 7.0.0
47 version: 7.0.056 version: 7.0.0
...@@ -60,6 +69,9 @@ catalogs:...@@ -60,6 +69,9 @@ catalogs:
60 remark-rehype:69 remark-rehype:
61 specifier: 11.1.270 specifier: 11.1.2
62 version: 11.1.271 version: 11.1.2
72 tldts:
73 specifier: 7.4.2
74 version: 7.4.2
63 typescript:75 typescript:
64 specifier: 7.0.276 specifier: 7.0.2
65 version: 7.0.277 version: 7.0.2
...@@ -117,6 +129,8 @@ overrides:...@@ -117,6 +129,8 @@ overrides:
117 '@opencode/protocol>effect': 4.0.2129 '@opencode/protocol>effect': 4.0.2
118 '@opencode/schema>effect': 4.0.2130 '@opencode/schema>effect': 4.0.2
119 node-abi: 4.33.0131 node-abi: 4.33.0
132 electron-webauthn>@electron-webauthn/macos: 1.3.1
133 '@electron-webauthn/macos>objc-js': 1.5.0
120 lightningcss: 1.33.0134 lightningcss: 1.33.0
121 tailwindcss: 4.3.3135 tailwindcss: 4.3.3
122 '@types/ws@^8': 8.18.2136 '@types/ws@^8': 8.18.2
...@@ -229,6 +243,9 @@ importers:...@@ -229,6 +243,9 @@ importers:
229 electron-updater:243 electron-updater:
230 specifier: ^6.8.9244 specifier: ^6.8.9
231 version: 6.8.9245 version: 6.8.9
246 electron-webauthn:
247 specifier: 'catalog:'
248 version: 1.3.1(typescript@7.0.2)
232 ffi-rs:249 ffi-rs:
233 specifier: 1.3.2250 specifier: 1.3.2
234 version: 1.3.2251 version: 1.3.2
...@@ -238,6 +255,9 @@ importers:...@@ -238,6 +255,9 @@ importers:
238 react-grab:255 react-grab:
239 specifier: ^0.1.32256 specifier: ^0.1.32
240 version: 0.1.44(react@19.2.6)257 version: 0.1.44(react@19.2.6)
258 tldts:
259 specifier: 'catalog:'
260 version: 7.4.2
241 devDependencies:261 devDependencies:
242 '@effect/vitest':262 '@effect/vitest':
243 specifier: 4.0.2263 specifier: 4.0.2
...@@ -1554,6 +1574,9 @@ importers:...@@ -1554,6 +1574,9 @@ importers:
1554 effect:1574 effect:
1555 specifier: 4.0.21575 specifier: 4.0.2
1556 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)1576 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
1577 plist:
1578 specifier: 'catalog:'
1579 version: 3.1.1
1557 pngjs:1580 pngjs:
1558 specifier: 7.0.01581 specifier: 7.0.0
1559 version: 7.0.01582 version: 7.0.0
...@@ -1564,6 +1587,9 @@ importers:...@@ -1564,6 +1587,9 @@ importers:
1564 '@effect/vitest':1587 '@effect/vitest':
1565 specifier: 4.0.21588 specifier: 4.0.2
1566 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)1589 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
1567 '@types/pngjs':1593 '@types/pngjs':
1568 specifier: 6.0.51594 specifier: 6.0.5
1569 version: 6.0.51595 version: 6.0.5
...@@ -2868,6 +2894,15 @@ packages:...@@ -2868,6 +2894,15 @@ packages:
2868 resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==}2894 resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==}
2869 engines: {node: '>=22.12.0'}2895 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
2871 '@electron/asar@3.4.1':2906 '@electron/asar@3.4.1':
2872 resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==}2907 resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==}
2873 engines: {node: '>=10.12.0'}2908 engines: {node: '>=10.12.0'}
...@@ -4215,9 +4250,32 @@ packages:...@@ -4215,9 +4250,32 @@ packages:
4215 '@opencode/schema@2.0.23':4250 '@opencode/schema@2.0.23':
4216 resolution: {integrity: sha512-DglA+CCNmCaiAJ8FqAKo2EKIkCOF9/EmMEYowcXhTrQcoJ7eaTx7ytGjPgis6VHQuU4aaAgwZYw96ypS3ojsIw==}4251 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
4218 '@oslojs/encoding@1.1.0':4272 '@oslojs/encoding@1.1.0':
4219 resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}4273 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
4221 '@oxc-parser/binding-android-arm-eabi@0.147.0':4279 '@oxc-parser/binding-android-arm-eabi@0.147.0':
4222 resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==}4280 resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==}
4223 engines: {node: ^20.19.0 || >=22.12.0}4281 engines: {node: ^20.19.0 || >=22.12.0}
...@@ -6049,6 +6107,9 @@ packages:...@@ -6049,6 +6107,9 @@ packages:
6049 '@types/node@24.12.4':6107 '@types/node@24.12.4':
6050 resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}6108 resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==}
60516109
6110 '@types/plist@3.0.5':
6111 resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==}
6112
6052 '@types/pngjs@6.0.5':6113 '@types/pngjs@6.0.5':
6053 resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}6114 resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==}
60546115
...@@ -7991,6 +8052,11 @@ packages:...@@ -7991,6 +8052,11 @@ packages:
7991 electron-updater@6.8.9:8052 electron-updater@6.8.9:
7992 resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==}8053 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
7994 electron-winstaller@5.4.0:8060 electron-winstaller@5.4.0:
7995 resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}8061 resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
7996 engines: {node: '>=8.0.0'}8062 engines: {node: '>=8.0.0'}
...@@ -9813,6 +9879,10 @@ packages:...@@ -9813,6 +9879,10 @@ packages:
9813 node-addon-api@7.1.1:9879 node-addon-api@7.1.1:
9814 resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}9880 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
9816 node-api-version@0.2.1:9886 node-api-version@0.2.1:
9817 resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}9887 resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==}
98189888
...@@ -9908,6 +9978,10 @@ packages:...@@ -9908,6 +9978,10 @@ packages:
9908 resolution: {integrity: sha512-i8iA8uij0g1YQzS8uOJPSRCgwDjO9warIHUAu1Fqj877Wc3wlfxDYBioYWgKTBF2+URVJttyDWSEpmd99nlvtQ==}9978 resolution: {integrity: sha512-i8iA8uij0g1YQzS8uOJPSRCgwDjO9warIHUAu1Fqj877Wc3wlfxDYBioYWgKTBF2+URVJttyDWSEpmd99nlvtQ==}
9909 engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0}9979 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
9911 object-assign@4.1.1:9985 object-assign@4.1.1:
9912 resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}9986 resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
9913 engines: {node: '>=0.10.0'}9987 engines: {node: '>=0.10.0'}
...@@ -13599,6 +13673,16 @@ snapshots:...@@ -13599,6 +13673,16 @@ snapshots:
1359913673
13600 '@electron-internal/extract-zip@1.0.5': {}13674 '@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
13602 '@electron/asar@3.4.1':13686 '@electron/asar@3.4.1':
13603 dependencies:13687 dependencies:
13604 commander: 5.1.013688 commander: 5.1.0
...@@ -14991,8 +15075,39 @@ snapshots:...@@ -14991,8 +15075,39 @@ snapshots:
14991 '@standard-schema/spec': 1.1.015075 '@standard-schema/spec': 1.1.0
14992 effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)15076 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
14994 '@oslojs/encoding@1.1.0': {}15100 '@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
14996 '@oxc-parser/binding-android-arm-eabi@0.147.0':15111 '@oxc-parser/binding-android-arm-eabi@0.147.0':
14997 optional: true15112 optional: true
1499815113
...@@ -16589,6 +16704,11 @@ snapshots:...@@ -16589,6 +16704,11 @@ snapshots:
16589 dependencies:16704 dependencies:
16590 undici-types: 7.16.016705 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
16592 '@types/pngjs@6.0.5':16712 '@types/pngjs@6.0.5':
16593 dependencies:16713 dependencies:
16594 '@types/node': 24.12.416714 '@types/node': 24.12.4
...@@ -18436,6 +18556,13 @@ snapshots:...@@ -18436,6 +18556,13 @@ snapshots:
18436 transitivePeerDependencies:18556 transitivePeerDependencies:
18437 - supports-color18557 - 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
18439 electron-winstaller@5.4.0:18566 electron-winstaller@5.4.0:
18440 dependencies:18567 dependencies:
18441 '@electron/asar': 3.4.118568 '@electron/asar': 3.4.1
...@@ -20739,6 +20866,9 @@ snapshots:...@@ -20739,6 +20866,9 @@ snapshots:
2073920866
20740 node-addon-api@7.1.1: {}20867 node-addon-api@7.1.1: {}
2074120868
20869 node-addon-api@8.9.2:
20870 optional: true
20871
20742 node-api-version@0.2.1:20872 node-api-version@0.2.1:
20743 dependencies:20873 dependencies:
20744 semver: 7.8.520874 semver: 7.8.5
...@@ -20857,6 +20987,12 @@ snapshots:...@@ -20857,6 +20987,12 @@ snapshots:
20857 dependencies:20987 dependencies:
20858 flow-enums-runtime: 0.0.620988 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
20860 object-assign@4.1.1: {}20996 object-assign@4.1.1: {}
2086120997
20862 object-inspect@1.13.4: {}20998 object-inspect@1.13.4: {}
pnpm-workspace.yaml+12
...@@ -18,6 +18,8 @@ allowBuilds:...@@ -18,6 +18,8 @@ allowBuilds:
18 msgpackr-extract: true18 msgpackr-extract: true
19 msw: false19 msw: false
20 node-pty: true20 node-pty: true
21 # Ships N-API prebuilds; its install script only rebuilds from source.
22 objc-js: false
21 sharp: true23 sharp: true
22 utf-8-validate: false24 utf-8-validate: false
23 workerd: false25 workerd: false
...@@ -38,6 +40,7 @@ catalog:...@@ -38,6 +40,7 @@ catalog:
38 "@effect/sql-sqlite-do": 4.0.240 "@effect/sql-sqlite-do": 4.0.2
39 "@effect/tsgo": 0.46.141 "@effect/tsgo": 0.46.1
40 "@effect/vitest": 4.0.242 "@effect/vitest": 4.0.2
43 "@electron-webauthn/macos": 1.3.1
41 "@legendapp/list": 3.3.544 "@legendapp/list": 3.3.5
42 "@noble/curves": 1.9.145 "@noble/curves": 1.9.1
43 "@noble/hashes": 1.8.046 "@noble/hashes": 1.8.0
...@@ -46,11 +49,16 @@ catalog:...@@ -46,11 +49,16 @@ catalog:
46 "@tailwindcss/oxide": 4.3.349 "@tailwindcss/oxide": 4.3.3
47 "@tailwindcss/vite": 4.3.350 "@tailwindcss/vite": 4.3.3
48 "@types/node": 24.12.451 "@types/node": 24.12.4
52 "@types/plist": 3.0.5
49 "@types/ws": 8.18.253 "@types/ws": 8.18.2
50 effect: 4.0.254 effect: 4.0.2
55 electron-webauthn: 1.3.1
51 jose: 6.2.256 jose: 6.2.2
52 lightningcss: 1.33.057 lightningcss: 1.33.0
58 objc-js: 1.5.0
59 plist: 3.1.1
53 tailwindcss: 4.3.360 tailwindcss: 4.3.3
61 tldts: 7.4.2
54 typescript: 7.0.262 typescript: 7.0.2
55 undici: 8.11.263 undici: 8.11.2
56 vite: npm:@voidzero-dev/vite-plus-core@1.0.064 vite: npm:@voidzero-dev/vite-plus-core@1.0.0
...@@ -249,6 +257,10 @@ overrides:...@@ -249,6 +257,10 @@ overrides:
249 "@opencode/protocol>effect": "catalog:"257 "@opencode/protocol>effect": "catalog:"
250 "@opencode/schema>effect": "catalog:"258 "@opencode/schema>effect": "catalog:"
251 node-abi: 4.33.0259 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:"
252 lightningcss: "catalog:"264 lightningcss: "catalog:"
253 tailwindcss: "catalog:"265 tailwindcss: "catalog:"
254 # Ranges from @effect/platform-node, jsdom, unifont, Expo and libsql otherwise266 # Ranges from @effect/platform-node, jsdom, unifont, Expo and libsql otherwise
scripts/build-desktop-artifact.test.ts+90-1
...@@ -44,6 +44,7 @@ import {...@@ -44,6 +44,7 @@ import {
44 preflightMacDesktopBuild,44 preflightMacDesktopBuild,
45 preflightWindowsDesktopBuild,45 preflightWindowsDesktopBuild,
46 renderMacPasskeyEntitlements,46 renderMacPasskeyEntitlements,
47 resolveMacWebAuthnEntitlements,
47 resolveClerkPasskeyNativeArtifacts,48 resolveClerkPasskeyNativeArtifacts,
48 resolveMacPasskeySigningConfiguration,49 resolveMacPasskeySigningConfiguration,
49 resolveDesktopRuntimeDependencies,50 resolveDesktopRuntimeDependencies,
...@@ -95,6 +96,12 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";...@@ -95,6 +96,12 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts";
95import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";96import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess";
96import { symlinksSupported } from "@t3tools/shared/testing/symlinks";97import { 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
98// A minimal stand-in for the Linux CLI release archive: one top-level105// A minimal stand-in for the Linux CLI release archive: one top-level
99// directory named after the archive stem holding the executable, the web106// directory named after the archive stem holding the executable, the web
100// client, and the runtime externals with node-pty built from source.107// client, and the runtime externals with node-pty built from source.
...@@ -454,6 +461,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -454,6 +461,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
454 os: ["darwin"],461 os: ["darwin"],
455 cpu: ["x64"],462 cpu: ["x64"],
456 },463 },
464 packageExtensions: stagePackageExtensions,
457 });465 });
458 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "linux", arch: "x64" }), {466 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "linux", arch: "x64" }), {
459 supportedArchitectures: {467 supportedArchitectures: {
...@@ -461,6 +469,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -461,6 +469,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
461 cpu: ["x64"],469 cpu: ["x64"],
462 libc: ["glibc"],470 libc: ["glibc"],
463 },471 },
472 packageExtensions: stagePackageExtensions,
464 });473 });
465 // Windows stages only win32 natives; WSL runs the separately built Linux474 // Windows stages only win32 natives; WSL runs the separately built Linux
466 // CLI archive rather than anything installed here.475 // CLI archive rather than anything installed here.
...@@ -469,18 +478,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -469,18 +478,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
469 os: ["win32"],478 os: ["win32"],
470 cpu: ["x64"],479 cpu: ["x64"],
471 },480 },
481 packageExtensions: stagePackageExtensions,
472 });482 });
473 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), {483 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), {
474 supportedArchitectures: {484 supportedArchitectures: {
475 os: ["win32"],485 os: ["win32"],
476 cpu: ["arm64"],486 cpu: ["arm64"],
477 },487 },
488 packageExtensions: stagePackageExtensions,
478 });489 });
479 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), {490 assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), {
480 supportedArchitectures: {491 supportedArchitectures: {
481 os: ["darwin"],492 os: ["darwin"],
482 cpu: ["arm64", "x64"],493 cpu: ["arm64", "x64"],
483 },494 },
495 packageExtensions: stagePackageExtensions,
484 });496 });
485 });497 });
486498
...@@ -507,6 +519,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -507,6 +519,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
507 cpu: ["x64"],519 cpu: ["x64"],
508 libc: ["glibc"],520 libc: ["glibc"],
509 },521 },
522 packageExtensions: stagePackageExtensions,
510 allowBuilds: {523 allowBuilds: {
511 electron: true,524 electron: true,
512 "node-pty": true,525 "node-pty": true,
...@@ -537,6 +550,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -537,6 +550,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
537 os: ["darwin"],550 os: ["darwin"],
538 cpu: ["arm64"],551 cpu: ["arm64"],
539 },552 },
553 packageExtensions: stagePackageExtensions,
540 },554 },
541 );555 );
542 });556 });
...@@ -1886,7 +1900,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1886,7 +1900,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1886 T3CODE_CLERK_PASSKEY_RP_DOMAINS:1900 T3CODE_CLERK_PASSKEY_RP_DOMAINS:
1887 " Clerk.Example.com,example.clerk.accounts.dev,clerk.example.com ",1901 " Clerk.Example.com,example.clerk.accounts.dev,clerk.example.com ",
1888 });1902 });
1889 const entitlements = renderMacPasskeyEntitlements(configuration);1903 const entitlements = renderMacPasskeyEntitlements(configuration, {
1904 touchIdKeychainAccessGroup: undefined,
1905 browserPasskeys: false,
1906 });
18901907
1891 assert.deepStrictEqual(configuration.rpDomains, [1908 assert.deepStrictEqual(configuration.rpDomains, [
1892 "clerk.example.com",1909 "clerk.example.com",
...@@ -1896,6 +1913,78 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {...@@ -1896,6 +1913,78 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => {
1896 assert.include(entitlements, "<string>webcredentials:clerk.example.com</string>");1913 assert.include(entitlements, "<string>webcredentials:clerk.example.com</string>");
1897 assert.include(entitlements, "<string>webcredentials:example.clerk.accounts.dev</string>");1914 assert.include(entitlements, "<string>webcredentials:example.clerk.accounts.dev</string>");
1898 assert.include(entitlements, "<key>com.apple.security.cs.allow-jit</key>");1915 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 );
1899 });1988 });
19001989
1901 it("rejects incomplete macOS passkey signing configuration", () => {1990 it("rejects incomplete macOS passkey signing configuration", () => {
scripts/build-desktop-artifact.ts+105-2
...@@ -4,6 +4,8 @@...@@ -4,6 +4,8 @@
4import * as NodeFSP from "node:fs/promises";4import * as NodeFSP from "node:fs/promises";
5import * as NodeCrypto from "node:crypto";5import * as NodeCrypto from "node:crypto";
6import * as NodeModule from "node:module";6import * as NodeModule from "node:module";
7// plist is CommonJS; Node cannot load its named exports from an ES module.
8import Plist from "plist";
79
8import {10import {
9 createPackageWithOptions,11 createPackageWithOptions,
...@@ -80,10 +82,28 @@ const StageWorkspaceConfig = Schema.Struct({...@@ -80,10 +82,28 @@ const StageWorkspaceConfig = Schema.Struct({
80 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),82 allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
81 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),83 patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)),
82 overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)),84 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 ),
83 nodeLinker: Schema.optional(Schema.Literals(["hoisted"])),96 nodeLinker: Schema.optional(Schema.Literals(["hoisted"])),
84});97});
85type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type;98type 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
87const RepoRoot = Effect.service(Path.Path).pipe(107const RepoRoot = Effect.service(Path.Path).pipe(
88 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),108 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
89);109);
...@@ -926,6 +946,7 @@ interface StagePackageJson {...@@ -926,6 +946,7 @@ interface StagePackageJson {
926 readonly version: string;946 readonly version: string;
927 readonly buildVersion: string;947 readonly buildVersion: string;
928 readonly t3codeCommitHash: string;948 readonly t3codeCommitHash: string;
949 readonly t3codeWebAuthn?: MacWebAuthnEntitlements;
929 readonly private: true;950 readonly private: true;
930 readonly packageManager: string;951 readonly packageManager: string;
931 readonly description: string;952 readonly description: string;
...@@ -1289,12 +1310,80 @@ function escapeXml(value: string): string {...@@ -1289,12 +1310,80 @@ function escapeXml(value: string): string {
1289 .replaceAll("'", "&apos;");1310 .replaceAll("'", "&apos;");
1290}1311}
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
1292export function renderMacPasskeyEntitlements(1368export function renderMacPasskeyEntitlements(
1293 configuration: MacPasskeySigningConfiguration,1369 configuration: MacPasskeySigningConfiguration,
1370 webAuthn: MacWebAuthnEntitlements,
1294): string {1371): string {
1295 const associatedDomains = configuration.rpDomains1372 const associatedDomains = configuration.rpDomains
1296 .map((domain) => ` <string>webcredentials:${escapeXml(domain)}</string>`)1373 .map((domain) => ` <string>webcredentials:${escapeXml(domain)}</string>`)
1297 .join("\n");1374 .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
1299 return `<?xml version="1.0" encoding="UTF-8"?>1388 return `<?xml version="1.0" encoding="UTF-8"?>
1300<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">1389<!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(...@@ -1307,7 +1396,7 @@ export function renderMacPasskeyEntitlements(
1307 <key>com.apple.developer.associated-domains</key>1396 <key>com.apple.developer.associated-domains</key>
1308 <array>1397 <array>
1309${associatedDomains}1398${associatedDomains}
1310 </array>1399 </array>${keychainAccessGroups}${browserPasskeys}
1311 <key>com.apple.security.cs.allow-jit</key>1400 <key>com.apple.security.cs.allow-jit</key>
1312 <true/>1401 <true/>
1313 <key>com.apple.security.cs.allow-unsigned-executable-memory</key>1402 <key>com.apple.security.cs.allow-unsigned-executable-memory</key>
...@@ -1535,6 +1624,7 @@ export function createStageWorkspaceConfig(input: {...@@ -1535,6 +1624,7 @@ export function createStageWorkspaceConfig(input: {
1535 ? { patchedDependencies }1624 ? { patchedDependencies }
1536 : {}),1625 : {}),
1537 ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),1626 ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}),
1627 packageExtensions: STAGE_PACKAGE_EXTENSIONS,
1538 };1628 };
1539}1629}
15401630
...@@ -3673,13 +3763,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3673,13 +3763,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3673 const macEntitlementsPath = macPasskeySigning3763 const macEntitlementsPath = macPasskeySigning
3674 ? path.join(stageAppDir, "entitlements.mac.plist")3764 ? path.join(stageAppDir, "entitlements.mac.plist")
3675 : undefined;3765 : undefined;
3766 let macWebAuthn: MacWebAuthnEntitlements | undefined;
3676 if (macPasskeySigning && macEntitlementsPath) {3767 if (macPasskeySigning && macEntitlementsPath) {
3677 if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) {3768 if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) {
3678 return yield* new MacProvisioningProfileNotFoundError({3769 return yield* new MacProvisioningProfileNotFoundError({
3679 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,3770 provisioningProfilePath: macPasskeySigning.provisioningProfilePath,
3680 });3771 });
3681 }3772 }
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 );
3683 }3784 }
36843785
3685 // Windows splits dependencies per process: app.asar carries only the3786 // Windows splits dependencies per process: app.asar carries only the
...@@ -3709,6 +3810,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (...@@ -3709,6 +3810,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* (
3709 version: appVersion,3810 version: appVersion,
3710 buildVersion: appVersion,3811 buildVersion: appVersion,
3711 t3codeCommitHash: commitHash,3812 t3codeCommitHash: commitHash,
3813 // Read by apps/desktop/src/preview/Passkeys.ts; must match the signed entitlements.
3814 ...(macWebAuthn ? { t3codeWebAuthn: macWebAuthn } : {}),
3712 private: true,3815 private: true,
3713 packageManager: rootPackageJson.packageManager,3816 packageManager: rootPackageJson.packageManager,
3714 description:3817 description:
scripts/lib/desktop-external-packages.ts+1
...@@ -16,6 +16,7 @@ export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [...@@ -16,6 +16,7 @@ export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [
16 "@napi-rs/keyring",16 "@napi-rs/keyring",
17 "@crowecawcaw/xa11y",17 "@crowecawcaw/xa11y",
18 "@clerk/electron-passkeys",18 "@clerk/electron-passkeys",
19 "electron-webauthn",
19 "ffi-rs",20 "ffi-rs",
20 "@yuuang/",21 "@yuuang/",
21 // Reads its own bundle from disk by resolving `playwright-core/package.json`22 // Reads its own bundle from disk by resolving `playwright-core/package.json`
scripts/package.json+2
...@@ -13,11 +13,13 @@...@@ -13,11 +13,13 @@
13 "@t3tools/shared": "workspace:*",13 "@t3tools/shared": "workspace:*",
14 "@t3tools/tailscale": "workspace:*",14 "@t3tools/tailscale": "workspace:*",
15 "effect": "catalog:",15 "effect": "catalog:",
16 "plist": "catalog:",
16 "pngjs": "7.0.0",17 "pngjs": "7.0.0",
17 "sharp": "0.35.4"18 "sharp": "0.35.4"
18 },19 },
19 "devDependencies": {20 "devDependencies": {
20 "@effect/vitest": "catalog:",21 "@effect/vitest": "catalog:",
22 "@types/plist": "catalog:",
21 "@types/pngjs": "6.0.5",23 "@types/pngjs": "6.0.5",
22 "typescript": "catalog:",24 "typescript": "catalog:",
23 "typescript-legacy": "npm:typescript@~6.0.3",25 "typescript-legacy": "npm:typescript@~6.0.3",
third-party-licenses.config.json+21
...@@ -278,6 +278,27 @@...@@ -278,6 +278,27 @@
278 "copyrights": ["Copyright (c) 2025 Stephen Crowe"]278 "copyrights": ["Copyright (c) 2025 Stephen Crowe"]
279 }279 }
280 },280 },
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 },
281 {302 {
282 "license": "MIT",303 "license": "MIT",
283 "name": "map-stream",304 "name": "map-stream",