From bfd26ec5a246b4725517a3de2c89a8ace4ffd772 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:07:24 -0700 Subject: [PATCH] feat(desktop): passkeys in the in-app browser on macOS Electron has no WebAuthn UI on macOS, so passkey prompts in preview tabs hung until they timed out. Signed builds now enable Electron's Touch ID authenticator, and, once Apple grants the managed browser passkey entitlement, route preview pages' WebAuthn through the system passkey sheet with the frame's real origin. Each path turns on only when the provisioning profile authorizes its entitlement. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/desktop/package.json | 4 +- apps/desktop/src/app/DesktopApp.ts | 3 + apps/desktop/src/main.ts | 2 + apps/desktop/src/preview-pick-preload.ts | 9 + apps/desktop/src/preview/GuestProtocol.ts | 4 + apps/desktop/src/preview/Manager.test.ts | 8 + apps/desktop/src/preview/Manager.ts | 7 + .../desktop/src/preview/PasskeyAttestation.ts | 59 ++++ .../desktop/src/preview/PasskeyBridge.test.ts | 246 ++++++++++++++ apps/desktop/src/preview/PasskeyBridge.ts | 288 ++++++++++++++++ apps/desktop/src/preview/Passkeys.test.ts | 310 ++++++++++++++++++ apps/desktop/src/preview/Passkeys.ts | 300 +++++++++++++++++ apps/desktop/src/window/DesktopWindow.test.ts | 11 + apps/desktop/src/window/DesktopWindow.ts | 9 + docs/operations/release.md | 10 + pnpm-lock.yaml | 124 +++++++ pnpm-workspace.yaml | 6 + scripts/build-desktop-artifact.test.ts | 91 ++++- scripts/build-desktop-artifact.ts | 106 +++++- scripts/lib/desktop-external-packages.ts | 1 + scripts/package.json | 2 + third-party-licenses.config.json | 21 ++ 22 files changed, 1617 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/preview/PasskeyAttestation.ts create mode 100644 apps/desktop/src/preview/PasskeyBridge.test.ts create mode 100644 apps/desktop/src/preview/PasskeyBridge.ts create mode 100644 apps/desktop/src/preview/Passkeys.test.ts create mode 100644 apps/desktop/src/preview/Passkeys.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ddef3ca9eb14..6fcd24e763d9 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -27,9 +27,11 @@ "electron": "44.4.2", "electron-store": "^8.2.0", "electron-updater": "^6.8.9", + "electron-webauthn": "1.3.1", "ffi-rs": "1.3.2", "playwright-core": "1.60.0", - "react-grab": "^0.1.32" + "react-grab": "^0.1.32", + "tldts": "7.4.2" }, "devDependencies": { "@effect/vitest": "catalog:", diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 08318bcd8da5..944077d0e396 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -17,6 +17,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as PreviewPasskeys from "../preview/Passkeys.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; @@ -276,6 +277,7 @@ const startup = Effect.gen(function* () { const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; + const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; yield* shellEnvironment.installIntoProcess; const hasCommandLinePasswordStore = @@ -329,6 +331,7 @@ const startup = Effect.gen(function* () { }); } yield* appIdentity.configure; + yield* previewPasskeys.configure; yield* applicationMenu.configure; yield* updates.configure; yield* DesktopRemoteUpdates.listen; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index f2f5234148ac..3ec736085a06 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -68,6 +68,7 @@ import * as BrowserImport from "./preview/BrowserImport/BrowserImport.ts"; import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret.ts"; import * as BrowserSession from "./preview/BrowserSession.ts"; import * as PreviewManager from "./preview/Manager.ts"; +import * as PreviewPasskeys from "./preview/Passkeys.ts"; import * as DesktopWindow from "./window/DesktopWindow.ts"; import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; @@ -165,6 +166,7 @@ const layerDesktopPreview = PreviewManager.layer.pipe( // service alongside the manager; both sit on the same BrowserSession. Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), Layer.provideMerge(BrowserSession.layer), + Layer.provideMerge(PreviewPasskeys.layer), Layer.provideMerge(layerDesktopFoundation), ); diff --git a/apps/desktop/src/preview-pick-preload.ts b/apps/desktop/src/preview-pick-preload.ts index 84e6abb29ee6..7c4f23e37f50 100644 --- a/apps/desktop/src/preview-pick-preload.ts +++ b/apps/desktop/src/preview-pick-preload.ts @@ -1 +1,10 @@ +import { ipcRenderer } from "electron"; + +import { PASSKEY_BRIDGE_ARGUMENT } from "./preview/GuestProtocol.ts"; +import { installPasskeyBridge } from "./preview/PasskeyBridge.ts"; import "./preview/PickPreload.ts"; + +// Electron hands webPreferences.additionalArguments to sandboxed preloads in process.argv. +if (process.argv.includes(PASSKEY_BRIDGE_ARGUMENT)) { + installPasskeyBridge((channel, publicKey) => ipcRenderer.invoke(channel, publicKey)); +} diff --git a/apps/desktop/src/preview/GuestProtocol.ts b/apps/desktop/src/preview/GuestProtocol.ts index 1a73bb30f29e..457c61ce670f 100644 --- a/apps/desktop/src/preview/GuestProtocol.ts +++ b/apps/desktop/src/preview/GuestProtocol.ts @@ -10,3 +10,7 @@ export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; export const RECORDING_KEY_CHANNEL = "preview:recording-key"; export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; +export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; +export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; +/** Renderer argument that turns on the guest passkey bridge; see Passkeys.ts. */ +export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index f731424e021b..8c78548e2a7f 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -26,6 +26,7 @@ import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts" import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as BrowserSession from "./BrowserSession.ts"; import * as PreviewManager from "./Manager.ts"; +import * as PreviewPasskeys from "./Passkeys.ts"; describe("fitPictureInPictureContentSize", () => { it("preserves the PiP content area across aspect-ratio changes", () => { @@ -276,6 +277,13 @@ const layer = PreviewManager.layer.pipe( }), ), Layer.provideMerge(layerBrowserSession), + Layer.provideMerge( + Layer.mock(PreviewPasskeys.PreviewPasskeys)({ + bridgeEnabled: false, + installSessionHandlers: () => {}, + attachGuest: () => () => {}, + }), + ), Layer.provideMerge(layerEnvironment), Layer.provideMerge(layerFileSystem), Layer.provideMerge(Path.layer), diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 3593ae5f5df6..482c8319a959 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -70,6 +70,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; import { PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; import * as BrowserSession from "./BrowserSession.ts"; +import * as PreviewPasskeys from "./Passkeys.ts"; import { ANNOTATION_CAPTURED_CHANNEL, ANNOTATION_THEME_CHANNEL, @@ -641,6 +642,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const fileSystem = yield* FileSystem.FileSystem; const rendererHistory = yield* DesktopRendererHistory.DesktopRendererHistory; + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; const hostPlatform = yield* HostProcessPlatform; const path = yield* Path.Path; const parentScope = yield* Scope.Scope; @@ -1803,6 +1805,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function ) { const scope = yield* Scope.fork(parentScope, "sequential"); const attachmentId = Symbol(); + let detachPasskeys = () => {}; let documentId = 0; let nextRequestId = 0; let activeCapture: { @@ -2115,6 +2118,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + detachPasskeys(); }).pipe(Effect.ignore), ); const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { @@ -2135,6 +2139,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); + detachPasskeys = passkeys.attachGuest(wc); wc.setWindowOpenHandler((details) => { if (previewWindowOpenAction(details) === "popup") { return { action: "allow", overrideBrowserWindowOptions: POPUP_WINDOW_OPTIONS }; @@ -5208,6 +5213,7 @@ export class PreviewManager extends Context.Service< export const make = Effect.gen(function* PreviewManagerMake() { const environment = yield* DesktopEnvironment.DesktopEnvironment; const browserSession = yield* BrowserSession.BrowserSession; + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; const operations = yield* makeNativeOperations( environment.browserArtifactsDir, environment.path.join(environment.dirname, "preview-pip-preload.cjs"), @@ -5225,6 +5231,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { ), ); operations.installDownloadHandler(session); + passkeys.installSessionHandlers(session); return session; }, ), diff --git a/apps/desktop/src/preview/PasskeyAttestation.ts b/apps/desktop/src/preview/PasskeyAttestation.ts new file mode 100644 index 000000000000..17927a2957da --- /dev/null +++ b/apps/desktop/src/preview/PasskeyAttestation.ts @@ -0,0 +1,59 @@ +/** + * Reads the raw `authData` bytes out of a CBOR attestation object + * (`{ fmt, attStmt, authData }`). Returns undefined for anything malformed. + * Only the definite-length encodings authenticators emit are understood. + */ +export const authenticatorDataFromAttestation = (bytes: Uint8Array) => { + let offset = 0; + const readHead = () => { + const initial = bytes[offset++]; + if (initial === undefined) return undefined; + const info = initial & 31; + let length = info; + if (info >= 24) { + if (info > 27) return undefined; + const size = 1 << (info - 24); + if (offset + size > bytes.length) return undefined; + length = 0; + for (let index = 0; index < size; index++) length = length * 256 + (bytes[offset++] ?? 0); + } + return { major: initial >> 5, length }; + }; + const skipValue = (): boolean => { + const head = readHead(); + if (!head) return false; + switch (head.major) { + case 2: + case 3: + offset += head.length; + return offset <= bytes.length; + case 4: + for (let index = 0; index < head.length; index++) if (!skipValue()) return false; + return true; + case 5: + for (let index = 0; index < head.length * 2; index++) if (!skipValue()) return false; + return true; + case 6: + return skipValue(); + default: + return true; + } + }; + + const map = readHead(); + if (map?.major !== 5) return undefined; + for (let entry = 0; entry < map.length; entry++) { + const key = readHead(); + if (key?.major !== 3 || offset + key.length > bytes.length) return undefined; + const name = new TextDecoder().decode(bytes.subarray(offset, offset + key.length)); + offset += key.length; + if (name !== "authData") { + if (!skipValue()) return undefined; + continue; + } + const value = readHead(); + if (value?.major !== 2 || offset + value.length > bytes.length) return undefined; + return bytes.slice(offset, offset + value.length); + } + return undefined; +}; diff --git a/apps/desktop/src/preview/PasskeyBridge.test.ts b/apps/desktop/src/preview/PasskeyBridge.test.ts new file mode 100644 index 000000000000..3ca93190ae51 --- /dev/null +++ b/apps/desktop/src/preview/PasskeyBridge.test.ts @@ -0,0 +1,246 @@ +import { assert, describe, it } from "@effect/vitest"; +import { afterEach, vi } from "vite-plus/test"; + +import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; +import { installPasskeyBridge } from "./PasskeyBridge.ts"; + +const base64Url = (bytes: ReadonlyArray) => Buffer.from(bytes).toString("base64url"); +const bytesOf = (buffer: ArrayBuffer | null) => (buffer ? [...new Uint8Array(buffer)] : null); + +const assertionData = { + credentialId: base64Url([1, 2, 3]), + clientDataJSON: base64Url([4]), + authenticatorData: base64Url([5]), + signature: base64Url([6, 7]), + userHandle: base64Url([8]), + extensions: { prf: { results: { first: base64Url([9]) } } }, +}; + +const attestationData = { + credentialId: base64Url([1, 2, 3]), + clientDataJSON: base64Url([4]), + attestationObject: base64Url([10]), + authData: base64Url([11]), + publicKey: base64Url([12]), + publicKeyAlgorithm: -7, + transports: [], + extensions: { credProps: { rk: true } }, +}; + +const creationOptions: PublicKeyCredentialCreationOptions = { + challenge: new Uint8Array([1]), + rp: { name: "Example" }, + user: { id: new Uint8Array([2]), name: "alice", displayName: "Alice" }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], +}; + +const illegalInvocation = (): never => { + throw new TypeError("Illegal invocation"); +}; + +/** + * Installs the bridge over fresh stand-ins for the page's DOM classes. Like + * Chromium's, their getters only work on objects the browser created itself. + */ +const install = (respond: (channel: string) => unknown) => { + const nativeCreate = vi.fn(async () => "native-create"); + const nativeGet = vi.fn(async () => "native-get"); + class FakeCredentialsContainer { + create() { + return nativeCreate(); + } + get() { + return nativeGet(); + } + } + class FakePublicKeyCredential { + get id() { + return illegalInvocation(); + } + get response() { + return illegalInvocation(); + } + } + class FakeAttestationResponse { + get clientDataJSON() { + return illegalInvocation(); + } + } + class FakeAssertionResponse { + get signature() { + return illegalInvocation(); + } + } + vi.stubGlobal("CredentialsContainer", FakeCredentialsContainer); + vi.stubGlobal("PublicKeyCredential", FakePublicKeyCredential); + vi.stubGlobal("AuthenticatorAttestationResponse", FakeAttestationResponse); + vi.stubGlobal("AuthenticatorAssertionResponse", FakeAssertionResponse); + + const invoke = vi.fn(async (channel: string, _publicKey: unknown) => respond(channel)); + installPasskeyBridge(invoke); + return { + invoke, + nativeCreate, + nativeGet, + credentials: new FakeCredentialsContainer() as unknown as CredentialsContainer, + FakePublicKeyCredential, + FakeAttestationResponse, + FakeAssertionResponse, + }; +}; + +describe("installPasskeyBridge", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("serves passkey sign-in through the main process with a credential pages can verify", async () => { + const { invoke, credentials, FakePublicKeyCredential, FakeAssertionResponse } = install(() => ({ + success: true, + data: assertionData, + })); + const challenge = new Uint8Array([0, 1, 2, 3]).subarray(1); + + const credential = (await credentials.get({ + publicKey: { + challenge, + rpId: "example.com", + allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }], + // Unknown members the page adds must not break IPC cloning. + extensions: { onHint: () => {} } as AuthenticationExtensionsClientInputs, + }, + })) as PublicKeyCredential; + + assert.deepStrictEqual(invoke.mock.calls[0], [ + PASSKEY_GET_CHANNEL, + { + challenge: new Uint8Array([1, 2, 3]), + rpId: "example.com", + allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }], + extensions: {}, + }, + ]); + const response = credential.response as AuthenticatorAssertionResponse; + assert.instanceOf(credential, FakePublicKeyCredential); + assert.instanceOf(response, FakeAssertionResponse); + assert.strictEqual(credential.id, assertionData.credentialId); + assert.deepStrictEqual(bytesOf(credential.rawId), [1, 2, 3]); + assert.deepStrictEqual(bytesOf(response.signature), [6, 7]); + assert.deepStrictEqual(bytesOf(response.userHandle), [8]); + assert.deepStrictEqual( + bytesOf(credential.getClientExtensionResults().prf?.results?.first as ArrayBuffer), + [9], + ); + assert.deepStrictEqual(credential.toJSON(), { + id: assertionData.credentialId, + rawId: assertionData.credentialId, + type: "public-key", + response: { + clientDataJSON: assertionData.clientDataJSON, + authenticatorData: assertionData.authenticatorData, + signature: assertionData.signature, + userHandle: assertionData.userHandle, + }, + clientExtensionResults: { prf: { results: { first: base64Url([9]) } } }, + }); + }); + + it("returns registrations with their attestation and public key", async () => { + const { invoke, credentials, FakeAttestationResponse } = install(() => ({ + success: true, + data: attestationData, + })); + + const credential = (await credentials.create({ + publicKey: creationOptions, + })) as PublicKeyCredential; + + assert.strictEqual(invoke.mock.calls[0]?.[0], PASSKEY_CREATE_CHANNEL); + const response = credential.response as AuthenticatorAttestationResponse; + assert.instanceOf(response, FakeAttestationResponse); + assert.isNull(credential.authenticatorAttachment); + assert.deepStrictEqual(bytesOf(response.attestationObject), [10]); + assert.deepStrictEqual(bytesOf(response.getAuthenticatorData()), [11]); + assert.deepStrictEqual(bytesOf(response.getPublicKey()), [12]); + assert.strictEqual(response.getPublicKeyAlgorithm(), -7); + assert.deepStrictEqual(credential.getClientExtensionResults(), { credProps: { rk: true } }); + assert.deepStrictEqual(credential.toJSON(), { + id: attestationData.credentialId, + rawId: attestationData.credentialId, + type: "public-key", + response: { + clientDataJSON: attestationData.clientDataJSON, + attestationObject: attestationData.attestationObject, + authenticatorData: attestationData.authData, + transports: [], + publicKey: attestationData.publicKey, + publicKeyAlgorithm: -7, + }, + clientExtensionResults: { credProps: { rk: true } }, + }); + }); + + it("leaves autofill and non-passkey requests to Chromium", async () => { + const { invoke, credentials, nativeCreate, nativeGet } = install(() => ({ + success: true, + data: assertionData, + })); + + await credentials.get({ + mediation: "conditional", + publicKey: { challenge: new Uint8Array([1]) }, + }); + await credentials.get({ password: true } as CredentialRequestOptions); + // Conditional create upgrades a password sign-in silently; no modal sheet. + const upgrade = { mediation: "conditional", publicKey: creationOptions } as const; + await credentials.create(upgrade); + await credentials.create({ password: {} } as CredentialCreationOptions); + assert.strictEqual(invoke.mock.calls.length, 0); + assert.strictEqual(nativeGet.mock.calls.length, 2); + assert.strictEqual(nativeCreate.mock.calls.length, 2); + assert.isFalse(await PublicKeyCredential.isConditionalMediationAvailable()); + assert.isTrue(await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()); + }); + + it("rejects with the WebAuthn error the ceremony ended with", async () => { + const { credentials } = install((channel) => ({ + success: false, + error: channel === PASSKEY_CREATE_CHANNEL ? "InvalidStateError" : "TypeError", + })); + const created = await credentials + .create({ publicKey: creationOptions }) + .catch((error: unknown) => error); + assert.instanceOf(created, DOMException); + assert.strictEqual((created as DOMException).name, "InvalidStateError"); + const fetched = await credentials + .get({ publicKey: { challenge: new Uint8Array([1]) } }) + .catch((error: unknown) => error); + assert.instanceOf(fetched, TypeError); + }); + + it("refuses when the main process has no handler for the page", async () => { + const { credentials } = install(() => { + throw new Error("Error invoking remote method 'preview:passkey-get': No handler registered"); + }); + + const error = await credentials + .get({ publicKey: { challenge: new Uint8Array([1]) } }) + .catch((caught: unknown) => caught); + assert.instanceOf(error, DOMException); + assert.strictEqual((error as DOMException).name, "NotAllowedError"); + }); + + it("rejects as soon as the page aborts, without waiting on the system sheet", async () => { + const { credentials } = install(() => new Promise(() => {})); + const controller = new AbortController(); + + const pending = credentials + .get({ publicKey: { challenge: new Uint8Array([1]) }, signal: controller.signal }) + .catch((error: unknown) => error); + controller.abort(); + + const error = await pending; + assert.instanceOf(error, DOMException); + assert.strictEqual((error as DOMException).name, "AbortError"); + }); +}); diff --git a/apps/desktop/src/preview/PasskeyBridge.ts b/apps/desktop/src/preview/PasskeyBridge.ts new file mode 100644 index 000000000000..67c8ba8388c3 --- /dev/null +++ b/apps/desktop/src/preview/PasskeyBridge.ts @@ -0,0 +1,288 @@ +import type { CreateCredentialResult, GetCredentialResult } from "electron-webauthn"; + +import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; + +/** What the main process answers a guest passkey ceremony with. */ +export type PasskeyCeremonyResult = + | Extract + | Extract + | { readonly success: false; readonly error: string }; + +type PasskeyChannel = typeof PASSKEY_CREATE_CHANNEL | typeof PASSKEY_GET_CHANNEL; +// Credential Management Level 1 added `mediation` to creation requests. +type CreationOptions = CredentialCreationOptions & { + readonly mediation?: CredentialMediationRequirement; +}; +type InvokePasskeyCeremony = (channel: PasskeyChannel, publicKey: unknown) => Promise; + +const NOT_ALLOWED_MESSAGE = + "The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client."; +const DOM_EXCEPTION_NAMES = new Set([ + "AbortError", + "InvalidStateError", + "NotAllowedError", + "NotSupportedError", + "SecurityError", +]); + +const fromBase64Url = (value: string) => { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + const binary = atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=")); + return Uint8Array.from(binary, (character) => character.charCodeAt(0)).buffer; +}; + +const toBase64Url = (buffer: ArrayBuffer) => + btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replaceAll("+", "-") + .replaceAll("/", "_") + .replace(/=+$/u, ""); + +const ceremonyError = (name: string) => + name === "TypeError" + ? new TypeError("The passkey request options are invalid.") + : new DOMException( + NOT_ALLOWED_MESSAGE, + DOM_EXCEPTION_NAMES.has(name) ? name : "NotAllowedError", + ); + +/** + * Copies request options into plain IPC-cloneable data. Chromium ignores + * members it does not know, so functions and other uncloneable values drop out + * here instead of failing the whole ceremony. + */ +export const toCloneable = (value: unknown): unknown => { + if (value instanceof ArrayBuffer) return value.slice(0); + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); + } + if (Array.isArray(value)) return value.map(toCloneable); + if (typeof value === "object" && value !== null) { + return Object.fromEntries( + Object.entries(value).flatMap(([key, member]) => + typeof member === "function" || member === undefined ? [] : [[key, toCloneable(member)]], + ), + ); + } + return value; +}; + +// The objects below borrow the native prototypes so `instanceof` checks pass. +// Their own properties shadow the native getters, which only work on objects +// Chromium created itself. +const withValues = (target: T, values: Record) => { + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(target, key, { value, enumerable: true, configurable: true }); + } + return target; +}; + +const prfOutputs = (prf: { + readonly enabled?: boolean; + readonly results?: { readonly first?: string; readonly second?: string }; +}): AuthenticationExtensionsPRFOutputs => { + const first = prf.results?.first; + const second = prf.results?.second; + return { + ...(prf.enabled === undefined ? {} : { enabled: prf.enabled }), + ...(first === undefined + ? {} + : { + results: { + first: fromBase64Url(first), + ...(second === undefined ? {} : { second: fromBase64Url(second) }), + }, + }), + }; +}; + +const extensionResultsJson = (results: AuthenticationExtensionsClientOutputs) => + JSON.parse( + JSON.stringify(results, (_key, value: unknown) => + value instanceof ArrayBuffer ? toBase64Url(value) : value, + ), + ) as unknown; + +const makeCredential = (input: { + readonly id: string; + readonly authenticatorAttachment: AuthenticatorAttachment | null; + readonly response: AuthenticatorResponse; + readonly responseJson: Record; + readonly extensionResults: AuthenticationExtensionsClientOutputs; +}) => + withValues(Object.create(PublicKeyCredential.prototype) as PublicKeyCredential, { + id: input.id, + rawId: fromBase64Url(input.id), + type: "public-key", + authenticatorAttachment: input.authenticatorAttachment, + response: input.response, + getClientExtensionResults: () => input.extensionResults, + toJSON: () => ({ + id: input.id, + rawId: input.id, + type: "public-key", + ...(input.authenticatorAttachment === null + ? {} + : { authenticatorAttachment: input.authenticatorAttachment }), + response: input.responseJson, + clientExtensionResults: extensionResultsJson(input.extensionResults), + }), + }); + +export const credentialFromCreateResult = ( + data: Extract["data"], +) => { + const transports = [...data.transports]; + const publicKey = data.publicKey.length > 0 ? data.publicKey : null; + const extensionResults: AuthenticationExtensionsClientOutputs = { + ...(data.extensions.credProps ? { credProps: { ...data.extensions.credProps } } : {}), + ...(data.extensions.prf ? { prf: prfOutputs(data.extensions.prf) } : {}), + ...(data.extensions.largeBlob ? { largeBlob: { ...data.extensions.largeBlob } } : {}), + }; + return makeCredential({ + id: data.credentialId, + // The native layer cannot tell a synced passkey from a security key. + authenticatorAttachment: null, + response: withValues( + Object.create(AuthenticatorAttestationResponse.prototype) as AuthenticatorAttestationResponse, + { + clientDataJSON: fromBase64Url(data.clientDataJSON), + attestationObject: fromBase64Url(data.attestationObject), + getTransports: () => [...transports], + getAuthenticatorData: () => fromBase64Url(data.authData), + getPublicKey: () => (publicKey === null ? null : fromBase64Url(publicKey)), + getPublicKeyAlgorithm: () => data.publicKeyAlgorithm, + }, + ), + responseJson: { + clientDataJSON: data.clientDataJSON, + attestationObject: data.attestationObject, + authenticatorData: data.authData, + transports, + ...(publicKey === null ? {} : { publicKey }), + publicKeyAlgorithm: data.publicKeyAlgorithm, + }, + extensionResults, + }); +}; + +export const credentialFromGetResult = ( + data: Extract["data"], +) => { + const userHandle = data.userHandle.length > 0 ? data.userHandle : null; + const { prf, largeBlob } = data.extensions ?? {}; + const extensionResults: AuthenticationExtensionsClientOutputs = { + ...(prf ? { prf: prfOutputs(prf) } : {}), + ...(largeBlob + ? { + largeBlob: { + ...(largeBlob.blob === undefined ? {} : { blob: fromBase64Url(largeBlob.blob) }), + ...(largeBlob.written === undefined ? {} : { written: largeBlob.written }), + }, + } + : {}), + }; + return makeCredential({ + id: data.credentialId, + authenticatorAttachment: null, + response: withValues( + Object.create(AuthenticatorAssertionResponse.prototype) as AuthenticatorAssertionResponse, + { + clientDataJSON: fromBase64Url(data.clientDataJSON), + authenticatorData: fromBase64Url(data.authenticatorData), + signature: fromBase64Url(data.signature), + userHandle: userHandle === null ? null : fromBase64Url(userHandle), + }, + ), + responseJson: { + clientDataJSON: data.clientDataJSON, + authenticatorData: data.authenticatorData, + signature: data.signature, + ...(userHandle === null ? {} : { userHandle }), + }, + extensionResults, + }); +}; + +const abortReason = (signal: AbortSignal) => + signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); + +/** + * Routes this page's WebAuthn ceremonies to the system passkey sheet through + * the main process, which supplies the frame's real origin. Runs in the page's + * own world (the preview preload has contextIsolation off), before any page + * script. Conditional (autofill and automatic upgrade) requests stay native: + * the system sheet is modal and must not open on its own. + */ +export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { + // Insecure contexts have no WebAuthn to bridge. + if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") { + return; + } + const run = async ( + channel: PasskeyChannel, + publicKey: unknown, + signal: AbortSignal | undefined, + ): Promise => { + if (signal?.aborted) throw abortReason(signal); + const ceremony = invoke(channel, toCloneable(publicKey)).then( + (value) => { + const result = value as PasskeyCeremonyResult; + if (!result.success) throw ceremonyError(result.error); + return "attestationObject" in result.data + ? credentialFromCreateResult(result.data) + : credentialFromGetResult(result.data); + }, + // No handler yet (or any more) for this guest: answer like a refusal. + (error: unknown) => { + throw error instanceof DOMException || error instanceof TypeError + ? error + : ceremonyError("NotAllowedError"); + }, + ); + if (!signal) return ceremony; + return new Promise((resolve, reject) => { + const onAbort = () => reject(abortReason(signal)); + signal.addEventListener("abort", onAbort, { once: true }); + ceremony.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); + }); + }; + + const container = CredentialsContainer.prototype; + const nativeCreate = container.create; + const nativeGet = container.get; + const define = (target: object, key: string, value: unknown) => + Object.defineProperty(target, key, { value, configurable: true, writable: true }); + + define( + container, + "create", + function create(this: CredentialsContainer, options?: CreationOptions) { + return options?.publicKey && options.mediation !== "conditional" + ? run(PASSKEY_CREATE_CHANNEL, options.publicKey, options.signal) + : Reflect.apply(nativeCreate, this, [options]); + }, + ); + define( + container, + "get", + function get(this: CredentialsContainer, options?: CredentialRequestOptions) { + return options?.publicKey && options.mediation !== "conditional" + ? run(PASSKEY_GET_CHANNEL, options.publicKey, options.signal) + : Reflect.apply(nativeGet, this, [options]); + }, + ); + + define(PublicKeyCredential, "isUserVerifyingPlatformAuthenticatorAvailable", async () => true); + define(PublicKeyCredential, "isConditionalMediationAvailable", async () => false); + const nativeCapabilities = PublicKeyCredential.getClientCapabilities; + if (typeof nativeCapabilities === "function") { + define(PublicKeyCredential, "getClientCapabilities", async () => ({ + ...(await Reflect.apply(nativeCapabilities, PublicKeyCredential, [])), + conditionalCreate: false, + conditionalGet: false, + hybridTransport: true, + passkeyPlatformAuthenticator: true, + userVerifyingPlatformAuthenticator: true, + })); + } +} diff --git a/apps/desktop/src/preview/Passkeys.test.ts b/apps/desktop/src/preview/Passkeys.test.ts new file mode 100644 index 000000000000..2a517600fb1b --- /dev/null +++ b/apps/desktop/src/preview/Passkeys.test.ts @@ -0,0 +1,310 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { beforeEach, vi } from "vite-plus/test"; + +const electron = vi.hoisted(() => ({ + configureWebAuthn: vi.fn(), + showMessageBox: vi.fn(), + fromWebContents: vi.fn(), +})); +const webauthn = vi.hoisted(() => ({ createCredential: vi.fn(), getCredential: vi.fn() })); + +vi.mock("electron", () => ({ + app: { configureWebAuthn: electron.configureWebAuthn }, + dialog: { showMessageBox: electron.showMessageBox }, + BrowserWindow: { fromWebContents: electron.fromWebContents }, + webContents: { fromFrame: () => undefined }, +})); +vi.mock("electron-webauthn", () => webauthn); + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; +import * as PreviewPasskeys from "./Passkeys.ts"; + +const layerFor = (packageJson: object, isPackaged = true) => + PreviewPasskeys.layer.pipe( + Layer.provide( + Layer.succeed( + DesktopEnvironment.DesktopEnvironment, + DesktopEnvironment.DesktopEnvironment.of({ + platform: "darwin", + isPackaged, + appRoot: "/app", + path: { join: (...parts: ReadonlyArray) => parts.join("/") }, + } as DesktopEnvironment.DesktopEnvironment["Service"]), + ), + ), + Layer.provide( + FileSystem.layerNoop({ + readFileString: () => Effect.succeed(JSON.stringify(packageJson)), + }), + ), + ); + +type Handler = (event: { readonly senderFrame: object | null }, publicKey: unknown) => unknown; + +const makeGuest = (origin = "https://accounts.example.com") => { + const handlers = new Map(); + const state = { focused: true }; + const mainFrame = { origin, isDestroyed: () => false }; + const guest = { + mainFrame, + hostWebContents: {}, + isFocused: () => state.focused, + ipc: { + handle: (channel: string, handler: Handler) => handlers.set(channel, handler), + removeHandler: (channel: string) => handlers.delete(channel), + }, + }; + const call = (channel: string, publicKey: unknown, senderFrame: object | null = mainFrame) => + Effect.promise(async () => handlers.get(channel)?.({ senderFrame }, publicKey)); + return { guest: guest as unknown as Electron.WebContents, mainFrame, handlers, state, call }; +}; + +const bridgeLayer = layerFor({ t3codeWebAuthn: { browserPasskeys: true } }); + +/** CBOR `{ fmt: "packed", attStmt: { alg: -7, sig }, authData }`, as authenticators encode it. */ +const attestationObject = (authData: ReadonlyArray) => { + const text = (value: string) => [0x60 + value.length, ...Buffer.from(value)]; + const bytes = (value: ReadonlyArray) => [0x58, value.length, ...value]; + return Buffer.from([ + 0xa3, + ...text("fmt"), + ...text("packed"), + ...text("attStmt"), + 0xa2, + ...text("alg"), + 0x26, + ...text("sig"), + ...bytes(Array.from({ length: 70 }, () => 7)), + ...text("authData"), + ...bytes(authData), + ]).toString("base64url"); +}; + +describe("PreviewPasskeys", () => { + beforeEach(() => { + vi.clearAllMocks(); + electron.fromWebContents.mockReturnValue({ + isDestroyed: () => false, + getNativeWindowHandle: () => Buffer.from([1]), + }); + }); + + it.effect("pins each guest ceremony to the origin of the frame that asked", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const { guest, handlers, call } = makeGuest(); + webauthn.getCredential.mockResolvedValue({ + success: false, + error: "NotAllowedError", + errorObject: new Error("native detail"), + }); + + const detach = passkeys.attachGuest(guest); + const publicKey = { challenge: new Uint8Array([1]), rpId: "example.com" }; + const result = yield* call(PASSKEY_GET_CHANNEL, publicKey); + + assert.deepStrictEqual(result, { success: false, error: "NotAllowedError" }); + const [sentPublicKey, options] = webauthn.getCredential.mock.calls[0] ?? []; + assert.deepStrictEqual(sentPublicKey, publicKey); + assert.strictEqual(options.currentOrigin, "https://accounts.example.com"); + assert.strictEqual(options.topFrameOrigin, "https://accounts.example.com"); + assert.isTrue(options.isPublicSuffix("com")); + assert.isTrue(options.isPublicSuffix("github.io")); + assert.isFalse(options.isPublicSuffix("example.com")); + assert.isFalse(options.isPublicSuffix("localhost")); + + // WebAuthn defaults the RP ID to the caller's own host. + yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) }); + assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com"); + + // Only the main frame runs the preload; anything else is not the page. + const forged = yield* call(PASSKEY_CREATE_CHANNEL, publicKey, { + origin: "https://evil.example", + }); + assert.deepStrictEqual(forged, { success: false, error: "NotAllowedError" }); + assert.strictEqual(webauthn.createCredential.mock.calls.length, 0); + + detach(); + assert.strictEqual(handlers.size, 0); + }).pipe(Effect.provide(bridgeLayer)), + ); + + it.effect("registers ES256 keys only and reports their real authenticator data", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const { guest, call } = makeGuest(); + passkeys.attachGuest(guest); + webauthn.createCredential.mockResolvedValue({ + success: true, + data: { + credentialId: "AQ", + clientDataJSON: "Ag", + attestationObject: attestationObject([1, 2, 3, 4]), + authData: Buffer.from('{"parsed":true}').toString("base64url"), + publicKey: "BQ", + publicKeyAlgorithm: -7, + transports: ["hybrid", "internal"], + extensions: {}, + }, + }); + const publicKey = { + challenge: new Uint8Array([1]), + pubKeyCredParams: [ + { type: "public-key", alg: -8 }, + { type: "public-key", alg: -7 }, + { type: "public-key", alg: -257 }, + ], + }; + + const result = yield* call(PASSKEY_CREATE_CHANNEL, publicKey); + + assert.deepStrictEqual(webauthn.createCredential.mock.calls[0]?.[0].pubKeyCredParams, [ + { type: "public-key", alg: -7 }, + ]); + assert.deepInclude(result, { success: true }); + const data = (result as { readonly data: Record }).data; + assert.strictEqual(data.authData, Buffer.from([1, 2, 3, 4]).toString("base64url")); + assert.deepStrictEqual(data.transports, []); + + const unsupported = yield* call(PASSKEY_CREATE_CHANNEL, { + ...publicKey, + pubKeyCredParams: [{ type: "public-key", alg: -8 }], + }); + assert.deepStrictEqual(unsupported, { success: false, error: "NotSupportedError" }); + assert.strictEqual(webauthn.createCredential.mock.calls.length, 1); + }).pipe(Effect.provide(bridgeLayer)), + ); + + it.effect("opens the sheet only for a focused, secure page with nothing else pending", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const notAllowed = { success: false, error: "NotAllowedError" }; + const publicKey = { challenge: new Uint8Array([1]), rpId: "example.com" }; + + const lan = makeGuest("http://192.168.1.5:3000"); + passkeys.attachGuest(lan.guest); + assert.deepStrictEqual(yield* lan.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); + + const page = makeGuest(); + passkeys.attachGuest(page.guest); + page.state.focused = false; + assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); + assert.strictEqual(webauthn.getCredential.mock.calls.length, 0); + + page.state.focused = true; + let finish: (value: unknown) => void = () => {}; + webauthn.getCredential.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + const first = yield* Effect.forkChild(page.call(PASSKEY_GET_CHANNEL, publicKey)); + yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); + assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); + + // The page navigated while the sheet was up; its credential goes nowhere. + page.mainFrame.origin = "https://other.example"; + finish({ success: true, data: { credentialId: "AQ" } }); + assert.deepStrictEqual(yield* Fiber.join(first), notAllowed); + }).pipe(Effect.provide(bridgeLayer)), + ); + + it.effect("answers the page even when the native ceremony never settles", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const { guest, call } = makeGuest(); + passkeys.attachGuest(guest); + webauthn.getCredential.mockReturnValueOnce(new Promise(() => {})); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + + const pending = yield* Effect.forkChild( + call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]), timeout: 1_000 }), + ); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(6_000)); + vi.useRealTimers(); + + assert.deepStrictEqual(yield* Fiber.join(pending), { + success: false, + error: "NotAllowedError", + }); + }).pipe(Effect.provide(bridgeLayer)), + ); + + it.effect("turns each path on only for builds signed for it", () => + Effect.gen(function* () { + const entitled = yield* PreviewPasskeys.PreviewPasskeys.pipe( + Effect.provide( + layerFor({ + t3codeWebAuthn: { + touchIdKeychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn", + browserPasskeys: false, + }, + }), + ), + ); + yield* entitled.configure; + assert.deepStrictEqual(electron.configureWebAuthn.mock.calls, [ + [{ touchID: { keychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn" } }], + ]); + assert.isFalse(entitled.bridgeEnabled); + const { guest, handlers } = makeGuest(); + entitled.attachGuest(guest); + assert.strictEqual(handlers.size, 0); + + const unpackaged = yield* PreviewPasskeys.PreviewPasskeys.pipe( + Effect.provide( + layerFor( + { t3codeWebAuthn: { touchIdKeychainAccessGroup: "X", browserPasskeys: true } }, + false, + ), + ), + ); + yield* unpackaged.configure; + assert.strictEqual(electron.configureWebAuthn.mock.calls.length, 1); + assert.isFalse(unpackaged.bridgeEnabled); + }), + ); + + it.effect("asks which passkey to use only when a site has several", () => + Effect.gen(function* () { + const passkeys = yield* PreviewPasskeys.PreviewPasskeys; + const listeners: Array<(...args: ReadonlyArray) => void> = []; + const session = { + on: (_event: string, listener: (...args: ReadonlyArray) => void) => + listeners.push(listener), + } as unknown as Electron.Session; + passkeys.installSessionHandlers(session); + passkeys.installSessionHandlers(session); + assert.strictEqual(listeners.length, 1); + + const select = (accounts: ReadonlyArray) => + Effect.promise( + () => + new Promise((resolve) => + listeners[0]?.( + {}, + { relyingPartyId: "example.com", accounts, frame: null }, + (credentialId?: string) => resolve(credentialId), + ), + ), + ); + const alice = { credentialId: "alice", name: "alice@example.com" }; + const bob = { credentialId: "bob", displayName: "Bob", name: "bob@example.com" }; + + assert.strictEqual(yield* select([alice]), "alice"); + assert.strictEqual(electron.showMessageBox.mock.calls.length, 0); + + electron.showMessageBox.mockResolvedValueOnce({ response: 1 }); + assert.strictEqual(yield* select([alice, bob]), "bob"); + assert.deepStrictEqual(electron.showMessageBox.mock.calls[0]?.[0]?.buttons, [ + "alice@example.com", + "Bob (bob@example.com)", + "Cancel", + ]); + + electron.showMessageBox.mockResolvedValueOnce({ response: 2 }); + assert.isUndefined(yield* select([alice, bob])); + }).pipe(Effect.provide(layerFor({}))), + ); +}); diff --git a/apps/desktop/src/preview/Passkeys.ts b/apps/desktop/src/preview/Passkeys.ts new file mode 100644 index 000000000000..471597654920 --- /dev/null +++ b/apps/desktop/src/preview/Passkeys.ts @@ -0,0 +1,300 @@ +// @effect-diagnostics globalTimers:off - Ceremonies settle inside IPC handlers, outside an Effect runtime. +import type { IpcMainInvokeEvent, Session, WebContents, WebFrameMain } from "electron"; +import { app, BrowserWindow, dialog, webContents as electronWebContents } from "electron"; +import type { WebauthnGetRequestOptions } from "electron-webauthn"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import { getPublicSuffix } from "tldts"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; +import { authenticatorDataFromAttestation } from "./PasskeyAttestation.ts"; +import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; + +const PasskeyPackageMetadata = Schema.Struct({ + t3codeWebAuthn: Schema.optional( + Schema.Struct({ + touchIdKeychainAccessGroup: Schema.optional(Schema.String), + browserPasskeys: Schema.optional(Schema.Boolean), + }), + ), +}); +const decodePasskeyPackageMetadata = Schema.decodeEffect( + Schema.fromJsonString(PasskeyPackageMetadata), +); + +export class PreviewPasskeysConfigureError extends Schema.TaggedError()( + "PreviewPasskeysConfigureError", + { + keychainAccessGroup: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to enable Touch ID passkeys for the in-app browser (keychain group ${this.keychainAccessGroup}).`; + } +} + +/** + * Passkeys for in-app browser pages on macOS, where Electron has no WebAuthn UI + * of its own. Signed builds record which entitlements their provisioning + * profile granted (scripts/build-desktop-artifact.ts), and each path turns on + * only when its entitlement is present: + * + * - Touch ID: Electron's built-in platform authenticator. Passkeys it creates + * stay on this Mac, in T3 Code's keychain group. + * - Browser passkeys: Apple's managed browser entitlement lets the system sheet + * (iCloud Keychain, password managers, phones, security keys) serve any + * site. The guest preload hands each ceremony to this process, which pins + * the requesting frame's real origin. + * + * Windows needs neither: Chromium already uses Windows Hello there. + */ +export class PreviewPasskeys extends Context.Service< + PreviewPasskeys, + { + /** Whether preview pages route WebAuthn through the system passkey sheet. */ + readonly bridgeEnabled: boolean; + /** Turns on Touch ID passkeys when this build is entitled to them. Runs after app ready. */ + readonly configure: Effect.Effect; + /** Lets the user choose between several passkeys for one site. Idempotent. */ + readonly installSessionHandlers: (session: Session) => void; + /** Serves a preview guest's passkey ceremonies. Returns the detach function. */ + readonly attachGuest: (guest: WebContents) => () => void; + } +>()("@t3tools/desktop/preview/Passkeys/PreviewPasskeys") {} + +const notAllowed: PasskeyCeremonyResult = { success: false, error: "NotAllowedError" }; + +// WebAuthn rejects RP IDs that are public suffixes, private registries +// included, so one github.io site cannot mint passkeys for every other one. +const isPublicSuffix = (domain: string) => + domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain; + +const accountLabel = (account: Electron.WebAuthnAccount) => + account.displayName && account.name && account.displayName !== account.name + ? `${account.displayName} (${account.name})` + : (account.name ?? account.displayName ?? "Unnamed passkey"); + +const ownerWindow = (frame: WebFrameMain | null) => { + const contents = frame ? electronWebContents.fromFrame(frame) : undefined; + const owner = contents?.hostWebContents ?? contents; + return owner ? BrowserWindow.fromWebContents(owner) : null; +}; + +const chooseAccount = async (details: Electron.SelectWebauthnAccountDetails) => { + const [onlyAccount] = details.accounts; + // The Touch ID prompt that follows already asks the user to confirm. + if (details.accounts.length === 1) return onlyAccount?.credentialId; + const options: Electron.MessageBoxOptions = { + type: "none", + message: "Choose a passkey", + detail: details.relyingPartyId, + buttons: [...details.accounts.map(accountLabel), "Cancel"], + cancelId: details.accounts.length, + defaultId: 0, + noLink: true, + }; + const parent = ownerWindow(details.frame); + const { response } = parent + ? await dialog.showMessageBox(parent, options) + : await dialog.showMessageBox(options); + return details.accounts[response]?.credentialId; +}; + +type WebAuthn = typeof import("electron-webauthn"); +type Ceremony = ( + webauthn: WebAuthn, + options: WebauthnGetRequestOptions, +) => Promise; + +const ES256 = -7; +const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; +const NATIVE_MAX_TIMEOUT_MS = 60 * 60 * 1000; + +// Mirrors the native layer's own clamp, plus a margin: it can leave a ceremony +// unsettled, and the page must still get an answer. +const ceremonyDeadline = (timeout: unknown) => + (typeof timeout === "number" && timeout > 0 + ? Math.min(timeout, NATIVE_MAX_TIMEOUT_MS) + : NATIVE_DEFAULT_TIMEOUT_MS) + 5_000; + +const withDeadline = (ceremony: Promise, milliseconds: number) => { + let timer: ReturnType | undefined; + const deadline = new Promise((resolve) => { + timer = setTimeout(() => resolve(notAllowed), milliseconds); + }); + return Promise.race([ceremony, deadline]).finally(() => clearTimeout(timer)); +}; + +// Secure contexts only, as in Chromium: https, or http on this machine. +const isTrustworthyOrigin = (origin: string) => { + if (!URL.canParse(origin)) return false; + const { protocol, hostname } = new URL(origin); + return ( + protocol === "https:" || + (protocol === "http:" && + (hostname === "localhost" || + hostname.endsWith(".localhost") || + hostname === "127.0.0.1" || + hostname === "[::1]")) + ); +}; + +const createCeremony = + (publicKey: PublicKeyCredentialCreationOptions): Ceremony => + async (webauthn, options) => { + // The native layer only converts P-256 keys and never settles for any + // other algorithm, so ES256 is the only one it may negotiate. + const params: unknown = publicKey.pubKeyCredParams; + const allowsEs256 = + !Array.isArray(params) || + params.length === 0 || + params.some( + (param: unknown) => + typeof param === "object" && param !== null && "alg" in param && param.alg === ES256, + ); + if (!allowsEs256) return { success: false, error: "NotSupportedError" }; + const result = await webauthn.createCredential( + { ...publicKey, pubKeyCredParams: [{ type: "public-key", alg: ES256 }] }, + options, + ); + if (!result.success) return { success: false, error: result.error }; + // The native layer reports parsed authenticator data as JSON and claims + // every credential is a synced platform passkey; neither is reliable. + const authData = authenticatorDataFromAttestation( + Buffer.from(result.data.attestationObject, "base64url"), + ); + return { + success: true, + data: { + ...result.data, + authData: authData ? Buffer.from(authData).toString("base64url") : "", + transports: [], + }, + }; + }; + +const getCeremony = + (publicKey: PublicKeyCredentialRequestOptions, origin: string): Ceremony => + async (webauthn, options) => { + // WebAuthn defaults the RP ID to the caller's host; the native layer requires it. + const result = await webauthn.getCredential( + { ...publicKey, rpId: publicKey.rpId ?? new URL(origin).hostname }, + options, + ); + return result.success ? result : { success: false, error: result.error }; + }; + +/** @public Service construction is part of the canonical Effect module API. */ +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + + const metadata = + environment.platform === "darwin" && environment.isPackaged + ? yield* fileSystem + .readFileString(environment.path.join(environment.appRoot, "package.json")) + .pipe( + Effect.flatMap(decodePasskeyPackageMetadata), + Effect.map((parsed) => parsed.t3codeWebAuthn), + Effect.orElseSucceed(() => undefined), + ) + : undefined; + const keychainAccessGroup = metadata?.touchIdKeychainAccessGroup; + const bridgeEnabled = metadata?.browserPasskeys === true; + const sessionsWithHandlers = new WeakSet(); + + return PreviewPasskeys.of({ + bridgeEnabled, + configure: Effect.gen(function* () { + if (keychainAccessGroup === undefined) return; + yield* Effect.try({ + try: () => app.configureWebAuthn({ touchID: { keychainAccessGroup } }), + catch: (cause) => new PreviewPasskeysConfigureError({ keychainAccessGroup, cause }), + }).pipe(Effect.catch((error) => Effect.logWarning(error.message, { cause: error.cause }))); + }).pipe(Effect.withSpan("desktop.previewPasskeys.configure")), + installSessionHandlers: (session) => { + if (sessionsWithHandlers.has(session)) return; + sessionsWithHandlers.add(session); + session.on("select-webauthn-account", (_event, details, callback) => { + // The request stays pending until the callback runs, so it must run once. + void chooseAccount(details).then( + (credentialId) => callback(credentialId), + () => callback(), + ); + }); + }, + attachGuest: (guest) => { + if (!bridgeEnabled) return () => {}; + let ceremonyPending = false; + const serve = async ( + event: IpcMainInvokeEvent, + publicKey: { readonly timeout?: unknown } | undefined, + ceremony: (origin: string) => Ceremony, + ): Promise => { + // Only the guest's main frame runs the bridge preload. + const frame = event.senderFrame; + if (!frame || frame !== guest.mainFrame) return notAllowed; + // The page shares a JS world with the preload, so nothing it sends can + // be trusted for the origin. Read the committed origin before any await: + // the frame object outlives a navigation. + const origin = frame.origin; + // Like Chromium, only a focused page may open the sheet, one at a time. + if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) { + return notAllowed; + } + if (typeof publicKey !== "object" || publicKey === null) { + return { success: false, error: "TypeError" }; + } + const host = guest.hostWebContents + ? BrowserWindow.fromWebContents(guest.hostWebContents) + : null; + if (!host || host.isDestroyed()) return notAllowed; + + ceremonyPending = true; + try { + const webauthn = await import("electron-webauthn"); + const result = await withDeadline( + ceremony(origin)(webauthn, { + currentOrigin: origin, + topFrameOrigin: origin, + nativeWindowHandle: host.getNativeWindowHandle(), + isPublicSuffix, + }), + ceremonyDeadline(publicKey.timeout), + ); + // A credential minted for a document that navigated away belongs to nobody. + const sameDocument = + !frame.isDestroyed() && frame === guest.mainFrame && frame.origin === origin; + return sameDocument ? result : notAllowed; + } catch { + return notAllowed; + } finally { + ceremonyPending = false; + } + }; + const detach = () => { + guest.ipc.removeHandler(PASSKEY_CREATE_CHANNEL); + guest.ipc.removeHandler(PASSKEY_GET_CHANNEL); + }; + detach(); + // Option shapes are the page's to get wrong: the native layer validates + // them and answers with a TypeError. + guest.ipc.handle( + PASSKEY_CREATE_CHANNEL, + (event, publicKey: PublicKeyCredentialCreationOptions) => + serve(event, publicKey, () => createCeremony(publicKey)), + ); + guest.ipc.handle(PASSKEY_GET_CHANNEL, (event, publicKey: PublicKeyCredentialRequestOptions) => + serve(event, publicKey, (origin) => getCeremony(publicKey, origin)), + ); + return detach; + }, + }); +}).pipe(Effect.withSpan("PreviewPasskeys.make")); + +export const layer = Layer.effect(PreviewPasskeys, make); diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index cb374b8103de..0e1bc39ddcdc 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -56,6 +56,7 @@ import { import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as PreviewPasskeys from "../preview/Passkeys.ts"; const environmentInput = { dirname: "/repo/apps/desktop/dist-electron", @@ -317,6 +318,11 @@ function layerTest(input: { } satisfies ElectronShell.ElectronShell["Service"]), layerElectronTheme, layerElectronWindow, + Layer.mock(PreviewPasskeys.PreviewPasskeys)({ + bridgeEnabled: false, + installSessionHandlers: () => {}, + attachGuest: () => () => {}, + }), Layer.mock(PreviewManager.PreviewManager)({ getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, @@ -425,6 +431,11 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n } satisfies ElectronShell.ElectronShell["Service"]), layerElectronTheme, Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), + Layer.mock(PreviewPasskeys.PreviewPasskeys)({ + bridgeEnabled: false, + installSessionHandlers: () => {}, + attachGuest: () => () => {}, + }), Layer.mock(PreviewManager.PreviewManager)({ getBrowserSession: () => Effect.succeed({} as Electron.Session), setMainWindow: () => Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 4e8fb0969ad3..cea3521007ee 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -25,7 +25,9 @@ import { TRACKPAD_SCROLL_END_CHANNEL, WINDOW_FULLSCREEN_STATE_CHANNEL, } from "../ipc/channels.ts"; +import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts"; import * as PreviewManager from "../preview/Manager.ts"; +import * as PreviewPasskeys from "../preview/Passkeys.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -321,6 +323,7 @@ export const make = Effect.gen(function* () { const electronTheme = yield* ElectronTheme.ElectronTheme; const electronWindow = yield* ElectronWindow.ElectronWindow; const previewManager = yield* PreviewManager.PreviewManager; + const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; const electronApp = yield* ElectronApp.ElectronApp; @@ -527,6 +530,12 @@ export const make = Effect.gen(function* () { webPreferences.nodeIntegration = false; webPreferences.nodeIntegrationInSubFrames = false; webPreferences.contextIsolation = false; + if (previewPasskeys.bridgeEnabled) { + webPreferences.additionalArguments = [ + ...(webPreferences.additionalArguments ?? []), + PASSKEY_BRIDGE_ARGUMENT, + ]; + } }); const contextMenuContents = new WeakSet(); diff --git a/docs/operations/release.md b/docs/operations/release.md index baff2ed56b23..6be0d8fd90fe 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -553,6 +553,16 @@ Notes: - The workflow writes it to a temporary `AuthKey_.p8` file at runtime. - The workflow decodes `MACOS_PROVISIONING_PROFILE`, validates it with `security cms`, and passes it to the desktop packager. +- In-app browser passkeys depend on the same profile. The packager adds each entitlement only when + the profile grants it, because macOS will not launch an app that claims more than its profile + allows. The build log reports which ones it enabled. + - `keychain-access-groups` (`.com.t3tools.t3code.webauthn`) enables Touch ID passkeys. + Profiles that grant the team's keychain groups (`.*`) cover it. + - `com.apple.developer.web-browser.public-key-credential` lets the system passkey sheet (iCloud + Keychain, password managers, phones, security keys) serve any site. Apple grants it as a + managed capability: the Account Holder requests it through the + [macOS Browsers Passkeys form](https://developer.apple.com/contact/request/macos-browsers-passkeys/). + After approval, regenerate the profile and update `MACOS_PROVISIONING_PROFILE`. ## 3) Azure Trusted Signing setup (Windows) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39ebcca645f4..5655115aa6a5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,8 @@ overrides: '@opencode/protocol>effect': 4.0.1 '@opencode/schema>effect': 4.0.1 node-abi: 4.33.0 + electron-webauthn>@electron-webauthn/macos: 1.3.1 + '@electron-webauthn/macos>objc-js': 1.5.0 lightningcss: 1.33.0 tailwindcss: 4.3.3 vite: npm:@voidzero-dev/vite-plus-core@1.0.0 @@ -193,6 +195,9 @@ importers: electron-updater: specifier: ^6.8.9 version: 6.8.9 + electron-webauthn: + specifier: 1.3.1 + version: 1.3.1(typescript@7.0.2) ffi-rs: specifier: 1.3.2 version: 1.3.2 @@ -202,6 +207,9 @@ importers: react-grab: specifier: ^0.1.32 version: 0.1.44(react@19.2.6) + tldts: + specifier: 7.4.2 + version: 7.4.2 devDependencies: '@effect/vitest': specifier: 4.0.1 @@ -1113,6 +1121,9 @@ importers: effect: specifier: 4.0.1 version: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + plist: + specifier: 3.1.1 + version: 3.1.1 pngjs: specifier: 7.0.0 version: 7.0.0 @@ -1123,6 +1134,9 @@ importers: '@effect/vitest': specifier: 4.0.1 version: 4.0.1(patch_hash=359f6fb2f7b3ec145bb72208edb9034f02489791aa2491a55cdbd69bd56ee0d2)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f))(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) + '@types/plist': + specifier: 3.0.5 + version: 3.0.5 '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -2427,6 +2441,15 @@ packages: resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==} engines: {node: '>=22.12.0'} + '@electron-webauthn/macos@1.3.1': + resolution: {integrity: sha512-NJA4I83ulh3pOfkv7sBkEGecTxaCtjFUbGTAi+BLNWdFc463AY7DaUprsqbGr+WAnD8SIUpuHSO7vC0C+HPu6g==} + os: [darwin] + peerDependencies: + typescript: ^6.0.2 + + '@electron-webauthn/types@1.3.1': + resolution: {integrity: sha512-C7Jy6dg32/QzIAl1E110acjpp6+b67xqZJL4L0rL2X2VIOJx4m+vfcWl3AjC4JgkxUBcSMSNa85bay2/473y/Q==} + '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -3765,9 +3788,32 @@ packages: '@opencode/schema@2.0.23': resolution: {integrity: sha512-DglA+CCNmCaiAJ8FqAKo2EKIkCOF9/EmMEYowcXhTrQcoJ7eaTx7ytGjPgis6VHQuU4aaAgwZYw96ypS3ojsIw==} + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/cbor@1.0.0': + resolution: {integrity: sha512-AY6Lknexs7n2xp8Cgey95c+975VG7XOk4UEdRdNFxHmDDbuf47OC/LAVRsl14DeTLwo8W6xr3HLFwUFmKcndTQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/crypto@1.0.0': + resolution: {integrity: sha512-dVz8TkkgYdr3tlwxHd7SCYGxoN7ynwHLA0nei/Aq9C+ERU0BK+U8+/3soEzBUxUNKYBf42351DyJUZ2REla50w==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + + '@oslojs/encoding@1.0.0': + resolution: {integrity: sha512-dyIB0SdZgMm5BhGwdSp8rMxEFIopLKxDG1vxIBaiogyom6ZqH2aXPb6DEC2WzOOWKdPSq1cxdNeRx2wAn1Z+ZQ==} + '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oslojs/webauthn@1.0.0': + resolution: {integrity: sha512-2ZRpbt3msNURwvjmavzq9vrNlxUnWFBGMYqbC1kO3fYBLskL7r4DiLJT1wbtLoI+hclFwjhl48YhRFBl6RWg1A==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + '@oxc-parser/binding-android-arm-eabi@0.147.0': resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -5570,6 +5616,9 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/plist@3.0.5': + resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/pngjs@6.0.5': resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} @@ -7512,6 +7561,11 @@ packages: electron-updater@6.8.9: resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} + electron-webauthn@1.3.1: + resolution: {integrity: sha512-I8/SOjPfnIiVjN38eTX3BRZfuFDGzLWtlByh4OecmlVkfxQt9R/zCwEm4PquyvaqP+xtGgkFrBxwOuJ+DhWlDg==} + peerDependencies: + typescript: ^6.0.2 + electron-winstaller@5.4.0: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} @@ -9334,6 +9388,10 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + node-api-version@0.2.1: resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} @@ -9429,6 +9487,10 @@ packages: resolution: {integrity: sha512-i8iA8uij0g1YQzS8uOJPSRCgwDjO9warIHUAu1Fqj877Wc3wlfxDYBioYWgKTBF2+URVJttyDWSEpmd99nlvtQ==} engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} + objc-js@1.5.0: + resolution: {integrity: sha512-IHmouX5xYrE2UyGRRlpwY7u2HJVjF6P/P+jy6Vqga+Ya7RAf/hFeNkt4KgTkGUMM3VmnBVUEMJ0tKWY2RlYgKg==} + os: [darwin] + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -13131,6 +13193,16 @@ snapshots: '@electron-internal/extract-zip@1.0.5': {} + '@electron-webauthn/macos@1.3.1(typescript@7.0.2)': + dependencies: + '@electron-webauthn/types': 1.3.1 + '@oslojs/webauthn': 1.0.0 + objc-js: 1.5.0 + typescript: 7.0.2 + optional: true + + '@electron-webauthn/types@1.3.1': {} + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -14519,8 +14591,39 @@ snapshots: '@standard-schema/spec': 1.1.0 effect: 4.0.1(patch_hash=a33cba07c41f32374c2aaa86ea4a84d3ab9a872a78c94b65e929b19f8361856f) + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + optional: true + + '@oslojs/binary@1.0.0': + optional: true + + '@oslojs/cbor@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + optional: true + + '@oslojs/crypto@1.0.0': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + optional: true + + '@oslojs/encoding@1.0.0': + optional: true + '@oslojs/encoding@1.1.0': {} + '@oslojs/webauthn@1.0.0': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + '@oslojs/cbor': 1.0.0 + '@oslojs/crypto': 1.0.0 + '@oslojs/encoding': 1.0.0 + optional: true + '@oxc-parser/binding-android-arm-eabi@0.147.0': optional: true @@ -16086,6 +16189,11 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/plist@3.0.5': + dependencies: + '@types/node': 24.12.4 + xmlbuilder: 15.1.1 + '@types/pngjs@6.0.5': dependencies: '@types/node': 24.12.4 @@ -17933,6 +18041,13 @@ snapshots: transitivePeerDependencies: - supports-color + electron-webauthn@1.3.1(typescript@7.0.2): + dependencies: + '@electron-webauthn/types': 1.3.1 + typescript: 7.0.2 + optionalDependencies: + '@electron-webauthn/macos': 1.3.1(typescript@7.0.2) + electron-winstaller@5.4.0: dependencies: '@electron/asar': 3.4.1 @@ -20236,6 +20351,9 @@ snapshots: node-addon-api@7.1.1: {} + node-addon-api@8.9.2: + optional: true + node-api-version@0.2.1: dependencies: semver: 7.8.5 @@ -20354,6 +20472,12 @@ snapshots: dependencies: flow-enums-runtime: 0.0.6 + objc-js@1.5.0: + dependencies: + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + optional: true + object-assign@4.1.1: {} object-inspect@1.13.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7f74dc5ba16b..1ba47fb05c0f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,8 @@ allowBuilds: msgpackr-extract: true msw: false node-pty: true + # Ships N-API prebuilds; its install script only rebuilds from source. + objc-js: false sharp: true utf-8-validate: false workerd: false @@ -233,6 +235,10 @@ overrides: "@opencode/protocol>effect": "catalog:" "@opencode/schema>effect": "catalog:" node-abi: 4.33.0 + # The desktop app installs these without a lockfile when it packages them, so + # pin the native passkey code that runs in its main process. + "electron-webauthn>@electron-webauthn/macos": 1.3.1 + "@electron-webauthn/macos>objc-js": 1.5.0 lightningcss: "catalog:" tailwindcss: "catalog:" vite: "catalog:" diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index aa733d531631..fbe4a1be3bb6 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -44,6 +44,7 @@ import { preflightMacDesktopBuild, preflightWindowsDesktopBuild, renderMacPasskeyEntitlements, + resolveMacWebAuthnEntitlements, resolveClerkPasskeyNativeArtifacts, resolveMacPasskeySigningConfiguration, resolveDesktopRuntimeDependencies, @@ -95,6 +96,12 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +// Keeps pnpm from auto-installing TypeScript, a types-only peer, into the app. +const stagePackageExtensions = { + "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } }, + "@electron-webauthn/macos": { peerDependenciesMeta: { typescript: { optional: true } } }, +}; + // A minimal stand-in for the Linux CLI release archive: one top-level // directory named after the archive stem holding the executable, the web // client, and the runtime externals with node-pty built from source. @@ -454,6 +461,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { os: ["darwin"], cpu: ["x64"], }, + packageExtensions: stagePackageExtensions, }); assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "linux", arch: "x64" }), { supportedArchitectures: { @@ -461,6 +469,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { cpu: ["x64"], libc: ["glibc"], }, + packageExtensions: stagePackageExtensions, }); // Windows stages only win32 natives; WSL runs the separately built Linux // CLI archive rather than anything installed here. @@ -469,18 +478,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { os: ["win32"], cpu: ["x64"], }, + packageExtensions: stagePackageExtensions, }); assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), { supportedArchitectures: { os: ["win32"], cpu: ["arm64"], }, + packageExtensions: stagePackageExtensions, }); assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), { supportedArchitectures: { os: ["darwin"], cpu: ["arm64", "x64"], }, + packageExtensions: stagePackageExtensions, }); }); @@ -507,6 +519,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { cpu: ["x64"], libc: ["glibc"], }, + packageExtensions: stagePackageExtensions, allowBuilds: { electron: true, "node-pty": true, @@ -537,6 +550,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { os: ["darwin"], cpu: ["arm64"], }, + packageExtensions: stagePackageExtensions, }, ); }); @@ -1883,7 +1897,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { T3CODE_CLERK_PASSKEY_RP_DOMAINS: " Clerk.Example.com,example.clerk.accounts.dev,clerk.example.com ", }); - const entitlements = renderMacPasskeyEntitlements(configuration); + const entitlements = renderMacPasskeyEntitlements(configuration, { + touchIdKeychainAccessGroup: undefined, + browserPasskeys: false, + }); assert.deepStrictEqual(configuration.rpDomains, [ "clerk.example.com", @@ -1893,6 +1910,78 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { assert.include(entitlements, "webcredentials:clerk.example.com"); assert.include(entitlements, "webcredentials:example.clerk.accounts.dev"); assert.include(entitlements, "com.apple.security.cs.allow-jit"); + assert.notInclude(entitlements, "keychain-access-groups"); + assert.notInclude(entitlements, "com.apple.developer.web-browser.public-key-credential"); + }); + + it("grants in-app browser passkey entitlements only when the provisioning profile does", () => { + const configuration = { appId: "com.t3tools.t3code", teamId: "ABC1234567" }; + // Profiles are CMS envelopes around a plain XML plist. + const profile = (entitlements: string, outside = "") => + `0\x82\x1f\x9a\x06\t*\x86H${outside}Entitlements${entitlements}\x00\x01`; + const teamWildcardGroups = `keychain-access-groups + + ABC1234567.* + com.apple.token + `; + + assert.deepStrictEqual(resolveMacWebAuthnEntitlements(profile(""), configuration), { + touchIdKeychainAccessGroup: undefined, + browserPasskeys: false, + }); + assert.deepStrictEqual( + resolveMacWebAuthnEntitlements( + profile("keychain-access-groupsOTHERTEAM1.*"), + configuration, + ), + { touchIdKeychainAccessGroup: undefined, browserPasskeys: false }, + ); + // Only real values inside the Entitlements dict count: not comments, not + // explicit false, not keys elsewhere in the profile. + assert.deepStrictEqual( + resolveMacWebAuthnEntitlements( + profile( + ` + com.apple.developer.web-browser.public-key-credential`, + teamWildcardGroups, + ), + configuration, + ), + { touchIdKeychainAccessGroup: undefined, browserPasskeys: false }, + ); + assert.deepStrictEqual( + resolveMacWebAuthnEntitlements( + profile( + `${teamWildcardGroups} + com.apple.developer.web-browser.public-key-credential + `, + ), + configuration, + ), + { + touchIdKeychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn", + browserPasskeys: true, + }, + ); + + const entitlements = renderMacPasskeyEntitlements( + { ...configuration, rpDomains: ["clerk.example.com"], provisioningProfilePath: "" }, + resolveMacWebAuthnEntitlements( + profile( + `${teamWildcardGroups}com.apple.developer.web-browser.public-key-credential`, + ), + configuration, + ), + ); + assert.match( + entitlements, + /keychain-access-groups<\/key>\s*\s*ABC1234567\.com\.t3tools\.t3code\.webauthn<\/string>\s*<\/array>/u, + ); + assert.match( + entitlements, + /com\.apple\.developer\.web-browser\.public-key-credential<\/key>\s*/u, + ); }); it("rejects incomplete macOS passkey signing configuration", () => { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index f8f5a6ca2d52..a860de1aa088 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -4,6 +4,7 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeCrypto from "node:crypto"; import * as NodeModule from "node:module"; +import { parse as parsePlist } from "plist"; import { createPackageWithOptions, @@ -80,10 +81,28 @@ const StageWorkspaceConfig = Schema.Struct({ allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), + packageExtensions: Schema.optional( + Schema.Record( + Schema.String, + Schema.Struct({ + peerDependenciesMeta: Schema.Record( + Schema.String, + Schema.Struct({ optional: Schema.Boolean }), + ), + }), + ), + ), nodeLinker: Schema.optional(Schema.Literals(["hoisted"])), }); type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type; +// electron-webauthn declares TypeScript as a peer only for its typings. pnpm +// auto-installs missing peers, which would ship a compiler inside the app. +const STAGE_PACKAGE_EXTENSIONS = { + "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } }, + "@electron-webauthn/macos": { peerDependenciesMeta: { typescript: { optional: true } } }, +} as const; + const RepoRoot = Effect.service(Path.Path).pipe( Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), ); @@ -926,6 +945,7 @@ interface StagePackageJson { readonly version: string; readonly buildVersion: string; readonly t3codeCommitHash: string; + readonly t3codeWebAuthn?: MacWebAuthnEntitlements; readonly private: true; readonly packageManager: string; readonly description: string; @@ -1288,12 +1308,80 @@ function escapeXml(value: string): string { .replaceAll("'", "'"); } +/** + * Passkey entitlements for the in-app browser. Each is granted only when the + * provisioning profile authorizes it: macOS refuses to launch an app that + * claims a restricted entitlement its embedded profile does not carry. + */ +export interface MacWebAuthnEntitlements { + /** Keychain group for Electron's Touch ID passkeys. */ + readonly touchIdKeychainAccessGroup: string | undefined; + /** Apple's managed browser entitlement, which allows passkeys for any site. */ + readonly browserPasskeys: boolean; +} + +const BROWSER_PASSKEYS_ENTITLEMENT = "com.apple.developer.web-browser.public-key-credential"; + +const ProvisioningProfilePlist = Schema.Struct({ + Entitlements: Schema.Struct({ + "keychain-access-groups": Schema.optional(Schema.Array(Schema.String)), + [BROWSER_PASSKEYS_ENTITLEMENT]: Schema.optional(Schema.Boolean), + }), +}); +const isProvisioningProfilePlist = Schema.is(ProvisioningProfilePlist); + +/** + * Reads the Entitlements dict of the XML plist a provisioning profile wraps in + * its CMS envelope. Anything unreadable grants nothing. + */ +const readProfileEntitlements = (provisioningProfile: string) => { + const start = provisioningProfile.indexOf("", start); + if (start === -1 || end === -1) return undefined; + try { + const profile: unknown = parsePlist(provisioningProfile.slice(start, end + "".length)); + return isProvisioningProfilePlist(profile) ? profile.Entitlements : undefined; + } catch { + return undefined; + } +}; + +export function resolveMacWebAuthnEntitlements( + provisioningProfile: string, + configuration: Pick, +): MacWebAuthnEntitlements { + const entitlements = readProfileEntitlements(provisioningProfile); + const keychainAccessGroup = `${configuration.teamId}.${configuration.appId}.webauthn`; + const keychainGroupAuthorized = (entitlements?.["keychain-access-groups"] ?? []).some((group) => + group.endsWith("*") + ? keychainAccessGroup.startsWith(group.slice(0, -1)) + : group === keychainAccessGroup, + ); + return { + touchIdKeychainAccessGroup: keychainGroupAuthorized ? keychainAccessGroup : undefined, + browserPasskeys: entitlements?.[BROWSER_PASSKEYS_ENTITLEMENT] === true, + }; +} + export function renderMacPasskeyEntitlements( configuration: MacPasskeySigningConfiguration, + webAuthn: MacWebAuthnEntitlements, ): string { const associatedDomains = configuration.rpDomains .map((domain) => ` webcredentials:${escapeXml(domain)}`) .join("\n"); + const keychainAccessGroups = webAuthn.touchIdKeychainAccessGroup + ? ` + keychain-access-groups + + ${escapeXml(webAuthn.touchIdKeychainAccessGroup)} + ` + : ""; + const browserPasskeys = webAuthn.browserPasskeys + ? ` + ${BROWSER_PASSKEYS_ENTITLEMENT} + ` + : ""; return ` @@ -1306,7 +1394,7 @@ export function renderMacPasskeyEntitlements( com.apple.developer.associated-domains ${associatedDomains} - + ${keychainAccessGroups}${browserPasskeys} com.apple.security.cs.allow-jit com.apple.security.cs.allow-unsigned-executable-memory @@ -1534,6 +1622,7 @@ export function createStageWorkspaceConfig(input: { ? { patchedDependencies } : {}), ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}), + packageExtensions: STAGE_PACKAGE_EXTENSIONS, }; } @@ -3660,13 +3749,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const macEntitlementsPath = macPasskeySigning ? path.join(stageAppDir, "entitlements.mac.plist") : undefined; + let macWebAuthn: MacWebAuthnEntitlements | undefined; if (macPasskeySigning && macEntitlementsPath) { if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) { return yield* new MacProvisioningProfileNotFoundError({ provisioningProfilePath: macPasskeySigning.provisioningProfilePath, }); } - yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning)); + macWebAuthn = resolveMacWebAuthnEntitlements( + yield* fs.readFileString(macPasskeySigning.provisioningProfilePath), + macPasskeySigning, + ); + yield* Effect.log( + `[desktop-artifact] In-app browser passkeys: Touch ID ${macWebAuthn.touchIdKeychainAccessGroup ? "enabled" : "disabled"}, browser passkeys ${macWebAuthn.browserPasskeys ? "enabled" : "disabled"}.`, + ); + yield* fs.writeFileString( + macEntitlementsPath, + renderMacPasskeyEntitlements(macPasskeySigning, macWebAuthn), + ); } // Windows splits dependencies per process: app.asar carries only the @@ -3696,6 +3796,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( version: appVersion, buildVersion: appVersion, t3codeCommitHash: commitHash, + // Read by apps/desktop/src/preview/Passkeys.ts; must match the signed entitlements. + ...(macWebAuthn ? { t3codeWebAuthn: macWebAuthn } : {}), private: true, packageManager: rootPackageJson.packageManager, description: "T3 Code desktop build", diff --git a/scripts/lib/desktop-external-packages.ts b/scripts/lib/desktop-external-packages.ts index 919397a684d1..5fb2b2df15c0 100644 --- a/scripts/lib/desktop-external-packages.ts +++ b/scripts/lib/desktop-external-packages.ts @@ -16,6 +16,7 @@ export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [ "@napi-rs/keyring", "@crowecawcaw/xa11y", "@clerk/electron-passkeys", + "electron-webauthn", "ffi-rs", "@yuuang/", // Reads its own bundle from disk by resolving `playwright-core/package.json` diff --git a/scripts/package.json b/scripts/package.json index 30890bde1002..c6c15512e354 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -13,11 +13,13 @@ "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", + "plist": "3.1.1", "pngjs": "7.0.0", "sharp": "0.35.4" }, "devDependencies": { "@effect/vitest": "catalog:", + "@types/plist": "3.0.5", "@types/pngjs": "6.0.5", "typescript": "catalog:", "typescript-legacy": "npm:typescript@~6.0.3", diff --git a/third-party-licenses.config.json b/third-party-licenses.config.json index 3376a841e78d..9102be3af278 100644 --- a/third-party-licenses.config.json +++ b/third-party-licenses.config.json @@ -278,6 +278,27 @@ "copyrights": ["Copyright (c) 2025 Stephen Crowe"] } }, + { + "license": "MIT", + "repositoryUrl": "https://github.com/iamEvanYT/electron-webauthn", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2026 iamEvan"] + } + }, + { + "license": "MIT", + "name": "@electron-webauthn/types", + "sourceUrl": "https://github.com/iamEvanYT/electron-webauthn", + "generatedNotice": { + "licenseId": "MIT", + "copyrights": ["Copyright (c) 2026 iamEvan"] + } + }, + { + "license": "MIT", + "name": "objc-js" + }, { "license": "MIT", "name": "map-stream",