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",