| author | |
| committer | |
| log | 454b94a13aea918f27bb060d8d054d4cfb791bc2 |
| tree | 773c25e081c03a0b0792dad622ba9897c7b60103 |
| parent | 0a4d789128100ed7ccd6d42f454946aea8edfca8 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Ben Davis <bmdavis419@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>24 files changed, 1762 insertions(+), 4 deletions(-)
apps/desktop/package.json+3-1| ... | ... | @@ -27,9 +27,11 @@ |
| 27 | 27 | "electron": "44.4.5", |
| 28 | 28 | "electron-store": "^8.2.0", |
| 29 | 29 | "electron-updater": "^6.8.9", |
| 30 | "electron-webauthn": "catalog:", | |
| 30 | 31 | "ffi-rs": "1.3.2", |
| 31 | 32 | "playwright-core": "1.60.0", |
| 32 | "react-grab": "^0.1.32" | |
| 33 | "react-grab": "^0.1.32", | |
| 34 | "tldts": "catalog:" | |
| 33 | 35 | }, |
| 34 | 36 | "devDependencies": { |
| 35 | 37 | "@effect/vitest": "catalog:", |
apps/desktop/src/app/DesktopApp.ts+3| ... | ... | @@ -17,6 +17,7 @@ import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; |
| 17 | 17 | import * as DesktopClerk from "./DesktopClerk.ts"; |
| 18 | 18 | import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; |
| 19 | 19 | import * as DesktopWindow from "../window/DesktopWindow.ts"; |
| 20 | import * as PreviewPasskeys from "../preview/Passkeys.ts"; | |
| 20 | 21 | import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; |
| 21 | 22 | import * as DesktopEnvironment from "./DesktopEnvironment.ts"; |
| 22 | 23 | import * as DesktopLegacyLocalStorage from "./DesktopLegacyLocalStorage.ts"; |
| ... | ... | @@ -276,6 +277,7 @@ const startup = Effect.gen(function* () { |
| 276 | 277 | const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; |
| 277 | 278 | const updates = yield* DesktopUpdates.DesktopUpdates; |
| 278 | 279 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 280 | const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; | |
| 279 | 281 | |
| 280 | 282 | yield* shellEnvironment.installIntoProcess; |
| 281 | 283 | const hasCommandLinePasswordStore = |
| ... | ... | @@ -329,6 +331,7 @@ const startup = Effect.gen(function* () { |
| 329 | 331 | }); |
| 330 | 332 | } |
| 331 | 333 | yield* appIdentity.configure; |
| 334 | yield* previewPasskeys.configure; | |
| 332 | 335 | yield* applicationMenu.configure; |
| 333 | 336 | yield* updates.configure; |
| 334 | 337 | yield* DesktopRemoteUpdates.listen; |
apps/desktop/src/main.ts+2| ... | ... | @@ -70,6 +70,7 @@ import * as LinuxBrowserSecret from "./preview/BrowserImport/LinuxBrowserSecret. |
| 70 | 70 | import * as BrowserSession from "./preview/BrowserSession.ts"; |
| 71 | 71 | import * as DesktopBrowserHost from "./preview/DesktopBrowserHost.ts"; |
| 72 | 72 | import * as PreviewManager from "./preview/Manager.ts"; |
| 73 | import * as PreviewPasskeys from "./preview/Passkeys.ts"; | |
| 73 | 74 | import * as DesktopWindow from "./window/DesktopWindow.ts"; |
| 74 | 75 | import * as DesktopWslBackend from "./wsl/DesktopWslBackend.ts"; |
| 75 | 76 | import * as DesktopWslEnvironment from "./wsl/DesktopWslEnvironment.ts"; |
| ... | ... | @@ -168,6 +169,7 @@ const layerDesktopPreview = PreviewManager.layer.pipe( |
| 168 | 169 | // service alongside the manager; both sit on the same BrowserSession. |
| 169 | 170 | Layer.provideMerge(BrowserImport.layer.pipe(Layer.provide(LinuxBrowserSecret.layer))), |
| 170 | 171 | Layer.provideMerge(BrowserSession.layer), |
| 172 | Layer.provideMerge(PreviewPasskeys.layer), | |
| 171 | 173 | Layer.provideMerge(layerDesktopFoundation), |
| 172 | 174 | ); |
| 173 | 175 |
apps/desktop/src/preview-pick-preload.ts+9| ... | ... | @@ -1 +1,10 @@ |
| 1 | import { ipcRenderer } from "electron"; | |
| 2 | ||
| 3 | import { PASSKEY_BRIDGE_ARGUMENT } from "./preview/GuestProtocol.ts"; | |
| 4 | import { installPasskeyBridge } from "./preview/PasskeyBridge.ts"; | |
| 1 | 5 | import "./preview/PickPreload.ts"; |
| 6 | ||
| 7 | // Electron hands webPreferences.additionalArguments to sandboxed preloads in process.argv. | |
| 8 | if (process.argv.includes(PASSKEY_BRIDGE_ARGUMENT)) { | |
| 9 | installPasskeyBridge((channel, publicKey) => ipcRenderer.invoke(channel, publicKey)); | |
| 10 | } |
apps/desktop/src/preview/GuestProtocol.ts+4| ... | ... | @@ -11,3 +11,7 @@ export const RECORDING_POINTER_CHANNEL = "preview:recording-pointer"; |
| 11 | 11 | export const RECORDING_KEY_CHANNEL = "preview:recording-key"; |
| 12 | 12 | export const RECORDING_INPUT_CHANNEL = "preview:recording-input"; |
| 13 | 13 | export const RECORDING_CONTROLLER_CHANNEL = "preview:recording-controller"; |
| 14 | export const PASSKEY_CREATE_CHANNEL = "preview:passkey-create"; | |
| 15 | export const PASSKEY_GET_CHANNEL = "preview:passkey-get"; | |
| 16 | /** Renderer argument that turns on the guest passkey bridge; see Passkeys.ts. */ | |
| 17 | export const PASSKEY_BRIDGE_ARGUMENT = "--t3code-preview-passkey-bridge"; |
apps/desktop/src/preview/Manager.test.ts+8| ... | ... | @@ -29,6 +29,7 @@ import * as ElectronWindow from "../electron/ElectronWindow.ts"; |
| 29 | 29 | import * as BrowserSession from "./BrowserSession.ts"; |
| 30 | 30 | import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; |
| 31 | 31 | import * as PreviewManager from "./Manager.ts"; |
| 32 | import * as PreviewPasskeys from "./Passkeys.ts"; | |
| 32 | 33 | |
| 33 | 34 | describe("fitPictureInPictureContentSize", () => { |
| 34 | 35 | it("preserves the PiP content area across aspect-ratio changes", () => { |
| ... | ... | @@ -301,6 +302,13 @@ const managerLayer = (platform: NodeJS.Platform = "darwin") => |
| 301 | 302 | ), |
| 302 | 303 | Layer.provideMerge(DesktopBrowserHost.layer), |
| 303 | 304 | Layer.provideMerge(layerBrowserSession), |
| 305 | Layer.provideMerge( | |
| 306 | Layer.mock(PreviewPasskeys.PreviewPasskeys)({ | |
| 307 | bridgeEnabled: false, | |
| 308 | installSessionHandlers: () => {}, | |
| 309 | attachGuest: () => () => {}, | |
| 310 | }), | |
| 311 | ), | |
| 304 | 312 | Layer.provideMerge(layerEnvironment), |
| 305 | 313 | Layer.provideMerge(layerFileSystem), |
| 306 | 314 | Layer.provideMerge(Path.layer), |
apps/desktop/src/preview/Manager.ts+7| ... | ... | @@ -64,6 +64,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 64 | 64 | import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"; |
| 65 | 65 | import { MENU_ACTION_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL } from "../ipc/channels.ts"; |
| 66 | 66 | import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; |
| 67 | import * as PreviewPasskeys from "./Passkeys.ts"; | |
| 67 | 68 | import * as BrowserSession from "./BrowserSession.ts"; |
| 68 | 69 | import { |
| 69 | 70 | ANNOTATION_CAPTURED_CHANNEL, |
| ... | ... | @@ -548,6 +549,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 548 | 549 | const crypto = yield* Crypto.Crypto; |
| 549 | 550 | const parentScope = yield* Scope.Scope; |
| 550 | 551 | const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; |
| 552 | const passkeys = yield* PreviewPasskeys.PreviewPasskeys; | |
| 551 | 553 | const context = yield* Effect.context<never>(); |
| 552 | 554 | const runFork = Effect.runForkWith(context); |
| 553 | 555 | const resolvedArtifactDirectory = path.resolve(artifactDirectory); |
| ... | ... | @@ -1254,6 +1256,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1254 | 1256 | ) { |
| 1255 | 1257 | const scope = yield* Scope.fork(parentScope, "sequential"); |
| 1256 | 1258 | const attachmentId = Symbol(); |
| 1259 | let detachPasskeys = () => {}; | |
| 1257 | 1260 | let documentId = 0; |
| 1258 | 1261 | let nextRequestId = 0; |
| 1259 | 1262 | let activeCapture: { |
| ... | ... | @@ -1589,6 +1592,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1589 | 1592 | wc.ipc.off(HUMAN_INPUT_CHANNEL, humanInput); |
| 1590 | 1593 | wc.ipc.off(RECORDING_INPUT_CHANNEL, recordingInput); |
| 1591 | 1594 | wc.ipc.off(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); |
| 1595 | detachPasskeys(); | |
| 1592 | 1596 | }).pipe(Effect.ignore), |
| 1593 | 1597 | ); |
| 1594 | 1598 | const install = Effect.fn("PreviewManager.installWebContentsListeners")(function* () { |
| ... | ... | @@ -1609,6 +1613,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function |
| 1609 | 1613 | wc.ipc.on(HUMAN_INPUT_CHANNEL, humanInput); |
| 1610 | 1614 | wc.ipc.on(RECORDING_INPUT_CHANNEL, recordingInput); |
| 1611 | 1615 | wc.ipc.on(MOUSE_NAVIGATE_CHANNEL, mouseNavigate); |
| 1616 | detachPasskeys = passkeys.attachGuest(wc); | |
| 1612 | 1617 | wc.setWindowOpenHandler((details) => { |
| 1613 | 1618 | const action = previewWindowOpenAction(details); |
| 1614 | 1619 | if (action === "popup") { |
| ... | ... | @@ -3686,6 +3691,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3686 | 3691 | const environment = yield* DesktopEnvironment.DesktopEnvironment; |
| 3687 | 3692 | const browserSession = yield* BrowserSession.BrowserSession; |
| 3688 | 3693 | const browserHost = yield* DesktopBrowserHost.DesktopBrowserHost; |
| 3694 | const passkeys = yield* PreviewPasskeys.PreviewPasskeys; | |
| 3689 | 3695 | const downloadSessions = new WeakSet<Electron.Session>(); |
| 3690 | 3696 | const fileSystem = yield* FileSystem.FileSystem; |
| 3691 | 3697 | const path = yield* Path.Path; |
| ... | ... | @@ -3750,6 +3756,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { |
| 3750 | 3756 | ), |
| 3751 | 3757 | ); |
| 3752 | 3758 | placeServerDownloads(session); |
| 3759 | passkeys.installSessionHandlers(session); | |
| 3753 | 3760 | return session; |
| 3754 | 3761 | }, |
| 3755 | 3762 | ), |
apps/desktop/src/preview/PasskeyAttestation.ts created+59| ... | ... | @@ -0,0 +1,59 @@ |
| 1 | /** | |
| 2 | * Reads the raw `authData` bytes out of a CBOR attestation object | |
| 3 | * (`{ fmt, attStmt, authData }`). Returns undefined for anything malformed. | |
| 4 | * Only the definite-length encodings authenticators emit are understood. | |
| 5 | */ | |
| 6 | export const authenticatorDataFromAttestation = (bytes: Uint8Array) => { | |
| 7 | let offset = 0; | |
| 8 | const readHead = () => { | |
| 9 | const initial = bytes[offset++]; | |
| 10 | if (initial === undefined) return undefined; | |
| 11 | const info = initial & 31; | |
| 12 | let length = info; | |
| 13 | if (info >= 24) { | |
| 14 | if (info > 27) return undefined; | |
| 15 | const size = 1 << (info - 24); | |
| 16 | if (offset + size > bytes.length) return undefined; | |
| 17 | length = 0; | |
| 18 | for (let index = 0; index < size; index++) length = length * 256 + (bytes[offset++] ?? 0); | |
| 19 | } | |
| 20 | return { major: initial >> 5, length }; | |
| 21 | }; | |
| 22 | const skipValue = (): boolean => { | |
| 23 | const head = readHead(); | |
| 24 | if (!head) return false; | |
| 25 | switch (head.major) { | |
| 26 | case 2: | |
| 27 | case 3: | |
| 28 | offset += head.length; | |
| 29 | return offset <= bytes.length; | |
| 30 | case 4: | |
| 31 | for (let index = 0; index < head.length; index++) if (!skipValue()) return false; | |
| 32 | return true; | |
| 33 | case 5: | |
| 34 | for (let index = 0; index < head.length * 2; index++) if (!skipValue()) return false; | |
| 35 | return true; | |
| 36 | case 6: | |
| 37 | return skipValue(); | |
| 38 | default: | |
| 39 | return true; | |
| 40 | } | |
| 41 | }; | |
| 42 | ||
| 43 | const map = readHead(); | |
| 44 | if (map?.major !== 5) return undefined; | |
| 45 | for (let entry = 0; entry < map.length; entry++) { | |
| 46 | const key = readHead(); | |
| 47 | if (key?.major !== 3 || offset + key.length > bytes.length) return undefined; | |
| 48 | const name = new TextDecoder().decode(bytes.subarray(offset, offset + key.length)); | |
| 49 | offset += key.length; | |
| 50 | if (name !== "authData") { | |
| 51 | if (!skipValue()) return undefined; | |
| 52 | continue; | |
| 53 | } | |
| 54 | const value = readHead(); | |
| 55 | if (value?.major !== 2 || offset + value.length > bytes.length) return undefined; | |
| 56 | return bytes.slice(offset, offset + value.length); | |
| 57 | } | |
| 58 | return undefined; | |
| 59 | }; |
apps/desktop/src/preview/PasskeyBackend.ts created+26| ... | ... | @@ -0,0 +1,26 @@ |
| 1 | import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; | |
| 2 | ||
| 3 | /** What the shared bridge has already checked before a backend runs a ceremony. */ | |
| 4 | export interface PasskeyCeremonyContext { | |
| 5 | /** The committed origin of the frame that asked; never the page's own claim. */ | |
| 6 | readonly origin: string; | |
| 7 | /** The window the system sheet attaches to. */ | |
| 8 | readonly nativeWindowHandle: Buffer; | |
| 9 | } | |
| 10 | ||
| 11 | /** | |
| 12 | * One platform's way to create and use passkeys for a preview page. The | |
| 13 | * bridge in `Passkeys.ts` owns everything platform-neutral: which frame may | |
| 14 | * ask, focus, one ceremony at a time, the deadline, and dropping results for | |
| 15 | * a page that navigated away. A backend only talks to the platform. | |
| 16 | */ | |
| 17 | export interface PasskeyBackend { | |
| 18 | readonly create: ( | |
| 19 | publicKey: PublicKeyCredentialCreationOptions, | |
| 20 | context: PasskeyCeremonyContext, | |
| 21 | ) => Promise<PasskeyCeremonyResult>; | |
| 22 | readonly get: ( | |
| 23 | publicKey: PublicKeyCredentialRequestOptions, | |
| 24 | context: PasskeyCeremonyContext, | |
| 25 | ) => Promise<PasskeyCeremonyResult>; | |
| 26 | } |
apps/desktop/src/preview/PasskeyBackendMac.ts created+96| ... | ... | @@ -0,0 +1,96 @@ |
| 1 | import { getPublicSuffix } from "tldts"; | |
| 2 | ||
| 3 | import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts"; | |
| 4 | import { authenticatorDataFromAttestation } from "./PasskeyAttestation.ts"; | |
| 5 | import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; | |
| 6 | ||
| 7 | const ES256 = -7; | |
| 8 | ||
| 9 | // WebAuthn rejects RP IDs that are public suffixes, private registries | |
| 10 | // included, so one github.io site cannot mint passkeys for every other one. | |
| 11 | const isPublicSuffix = (domain: string) => | |
| 12 | domain !== "localhost" && getPublicSuffix(domain, { allowPrivateDomains: true }) === domain; | |
| 13 | ||
| 14 | // Loaded only once a ceremony runs: the module and its native addon ship on macOS alone. | |
| 15 | const nativeOptions = async (context: PasskeyCeremonyContext) => ({ | |
| 16 | webauthn: await import("electron-webauthn"), | |
| 17 | options: { | |
| 18 | currentOrigin: context.origin, | |
| 19 | topFrameOrigin: context.origin, | |
| 20 | nativeWindowHandle: context.nativeWindowHandle, | |
| 21 | isPublicSuffix, | |
| 22 | }, | |
| 23 | }); | |
| 24 | ||
| 25 | const isBufferSource = (value: unknown): value is BufferSource => | |
| 26 | value instanceof ArrayBuffer || ArrayBuffer.isView(value); | |
| 27 | ||
| 28 | const toBase64Url = (value: BufferSource) => | |
| 29 | Buffer.from( | |
| 30 | ArrayBuffer.isView(value) | |
| 31 | ? new Uint8Array(value.buffer, value.byteOffset, value.byteLength) | |
| 32 | : new Uint8Array(value), | |
| 33 | ).toString("base64url"); | |
| 34 | ||
| 35 | /** | |
| 36 | * macOS passkeys through AuthenticationServices (`electron-webauthn`): the | |
| 37 | * system sheet offers iCloud Keychain, password managers, phones, and security | |
| 38 | * keys. Needs Apple's managed browser entitlement in the signed build. | |
| 39 | */ | |
| 40 | export const macPasskeyBackend: PasskeyBackend = { | |
| 41 | create: async (publicKey, context): Promise<PasskeyCeremonyResult> => { | |
| 42 | // The native layer only converts P-256 keys and never settles for any | |
| 43 | // other algorithm, so ES256 is the only one it may negotiate. | |
| 44 | const params: unknown = publicKey.pubKeyCredParams; | |
| 45 | if (params !== undefined && !Array.isArray(params)) | |
| 46 | return { success: false, error: "TypeError" }; | |
| 47 | const allowsEs256 = | |
| 48 | params === undefined || | |
| 49 | params.length === 0 || | |
| 50 | params.some( | |
| 51 | (param: unknown) => | |
| 52 | typeof param === "object" && param !== null && "alg" in param && param.alg === ES256, | |
| 53 | ); | |
| 54 | if (!allowsEs256) return { success: false, error: "NotSupportedError" }; | |
| 55 | const { webauthn, options } = await nativeOptions(context); | |
| 56 | const result = await webauthn.createCredential( | |
| 57 | { ...publicKey, pubKeyCredParams: [{ type: "public-key", alg: ES256 }] }, | |
| 58 | options, | |
| 59 | ); | |
| 60 | if (!result.success) return { success: false, error: result.error }; | |
| 61 | // The native layer reports parsed authenticator data as JSON and claims | |
| 62 | // every credential is a synced platform passkey; neither is reliable. | |
| 63 | const authData = authenticatorDataFromAttestation( | |
| 64 | Buffer.from(result.data.attestationObject, "base64url"), | |
| 65 | ); | |
| 66 | // Sites verify the new credential from its authenticator data; without it | |
| 67 | // the passkey is unusable, so say so now rather than let sign-up fail later. | |
| 68 | if (!authData) return { success: false, error: "NotAllowedError" }; | |
| 69 | return { | |
| 70 | success: true, | |
| 71 | data: { | |
| 72 | ...result.data, | |
| 73 | authData: Buffer.from(authData).toString("base64url"), | |
| 74 | transports: [], | |
| 75 | }, | |
| 76 | }; | |
| 77 | }, | |
| 78 | get: async (publicKey, context): Promise<PasskeyCeremonyResult> => { | |
| 79 | const { webauthn, options } = await nativeOptions(context); | |
| 80 | // WebAuthn defaults the RP ID to the caller's host; the native layer requires it. | |
| 81 | const result = await webauthn.getCredential( | |
| 82 | { ...publicKey, rpId: publicKey.rpId ?? new URL(context.origin).hostname }, | |
| 83 | options, | |
| 84 | ); | |
| 85 | if (!result.success) return { success: false, error: result.error }; | |
| 86 | // The native layer applies the site's allow list to platform passkeys only, | |
| 87 | // so a security key can answer with a credential the site did not ask for. | |
| 88 | const allowed = (Array.isArray(publicKey.allowCredentials) ? publicKey.allowCredentials : []) | |
| 89 | .filter((credential) => credential?.type === "public-key" && isBufferSource(credential.id)) | |
| 90 | .map((credential) => toBase64Url(credential.id)); | |
| 91 | if (allowed.length > 0 && !allowed.includes(result.data.credentialId)) { | |
| 92 | return { success: false, error: "NotAllowedError" }; | |
| 93 | } | |
| 94 | return result; | |
| 95 | }, | |
| 96 | }; |
apps/desktop/src/preview/PasskeyBridge.test.ts created+246| ... | ... | @@ -0,0 +1,246 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import { afterEach, vi } from "vite-plus/test"; | |
| 3 | ||
| 4 | import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; | |
| 5 | import { installPasskeyBridge } from "./PasskeyBridge.ts"; | |
| 6 | ||
| 7 | const base64Url = (bytes: ReadonlyArray<number>) => Buffer.from(bytes).toString("base64url"); | |
| 8 | const bytesOf = (buffer: ArrayBuffer | null) => (buffer ? [...new Uint8Array(buffer)] : null); | |
| 9 | ||
| 10 | const assertionData = { | |
| 11 | credentialId: base64Url([1, 2, 3]), | |
| 12 | clientDataJSON: base64Url([4]), | |
| 13 | authenticatorData: base64Url([5]), | |
| 14 | signature: base64Url([6, 7]), | |
| 15 | userHandle: base64Url([8]), | |
| 16 | extensions: { prf: { results: { first: base64Url([9]) } } }, | |
| 17 | }; | |
| 18 | ||
| 19 | const attestationData = { | |
| 20 | credentialId: base64Url([1, 2, 3]), | |
| 21 | clientDataJSON: base64Url([4]), | |
| 22 | attestationObject: base64Url([10]), | |
| 23 | authData: base64Url([11]), | |
| 24 | publicKey: base64Url([12]), | |
| 25 | publicKeyAlgorithm: -7, | |
| 26 | transports: [], | |
| 27 | extensions: { credProps: { rk: true } }, | |
| 28 | }; | |
| 29 | ||
| 30 | const creationOptions: PublicKeyCredentialCreationOptions = { | |
| 31 | challenge: new Uint8Array([1]), | |
| 32 | rp: { name: "Example" }, | |
| 33 | user: { id: new Uint8Array([2]), name: "alice", displayName: "Alice" }, | |
| 34 | pubKeyCredParams: [{ type: "public-key", alg: -7 }], | |
| 35 | }; | |
| 36 | ||
| 37 | const illegalInvocation = (): never => { | |
| 38 | throw new TypeError("Illegal invocation"); | |
| 39 | }; | |
| 40 | ||
| 41 | /** | |
| 42 | * Installs the bridge over fresh stand-ins for the page's DOM classes. Like | |
| 43 | * Chromium's, their getters only work on objects the browser created itself. | |
| 44 | */ | |
| 45 | const install = (respond: (channel: string) => unknown) => { | |
| 46 | const nativeCreate = vi.fn(async () => "native-create"); | |
| 47 | const nativeGet = vi.fn(async () => "native-get"); | |
| 48 | class FakeCredentialsContainer { | |
| 49 | create() { | |
| 50 | return nativeCreate(); | |
| 51 | } | |
| 52 | get() { | |
| 53 | return nativeGet(); | |
| 54 | } | |
| 55 | } | |
| 56 | class FakePublicKeyCredential { | |
| 57 | get id() { | |
| 58 | return illegalInvocation(); | |
| 59 | } | |
| 60 | get response() { | |
| 61 | return illegalInvocation(); | |
| 62 | } | |
| 63 | } | |
| 64 | class FakeAttestationResponse { | |
| 65 | get clientDataJSON() { | |
| 66 | return illegalInvocation(); | |
| 67 | } | |
| 68 | } | |
| 69 | class FakeAssertionResponse { | |
| 70 | get signature() { | |
| 71 | return illegalInvocation(); | |
| 72 | } | |
| 73 | } | |
| 74 | vi.stubGlobal("CredentialsContainer", FakeCredentialsContainer); | |
| 75 | vi.stubGlobal("PublicKeyCredential", FakePublicKeyCredential); | |
| 76 | vi.stubGlobal("AuthenticatorAttestationResponse", FakeAttestationResponse); | |
| 77 | vi.stubGlobal("AuthenticatorAssertionResponse", FakeAssertionResponse); | |
| 78 | ||
| 79 | const invoke = vi.fn(async (channel: string, _publicKey: unknown) => respond(channel)); | |
| 80 | installPasskeyBridge(invoke); | |
| 81 | return { | |
| 82 | invoke, | |
| 83 | nativeCreate, | |
| 84 | nativeGet, | |
| 85 | credentials: new FakeCredentialsContainer() as unknown as CredentialsContainer, | |
| 86 | FakePublicKeyCredential, | |
| 87 | FakeAttestationResponse, | |
| 88 | FakeAssertionResponse, | |
| 89 | }; | |
| 90 | }; | |
| 91 | ||
| 92 | describe("installPasskeyBridge", () => { | |
| 93 | afterEach(() => { | |
| 94 | vi.unstubAllGlobals(); | |
| 95 | }); | |
| 96 | ||
| 97 | it("serves passkey sign-in through the main process with a credential pages can verify", async () => { | |
| 98 | const { invoke, credentials, FakePublicKeyCredential, FakeAssertionResponse } = install(() => ({ | |
| 99 | success: true, | |
| 100 | data: assertionData, | |
| 101 | })); | |
| 102 | const challenge = new Uint8Array([0, 1, 2, 3]).subarray(1); | |
| 103 | ||
| 104 | const credential = (await credentials.get({ | |
| 105 | publicKey: { | |
| 106 | challenge, | |
| 107 | rpId: "example.com", | |
| 108 | allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }], | |
| 109 | // Unknown members the page adds must not break IPC cloning. | |
| 110 | extensions: { onHint: () => {} } as AuthenticationExtensionsClientInputs, | |
| 111 | }, | |
| 112 | })) as PublicKeyCredential; | |
| 113 | ||
| 114 | assert.deepStrictEqual(invoke.mock.calls[0], [ | |
| 115 | PASSKEY_GET_CHANNEL, | |
| 116 | { | |
| 117 | challenge: new Uint8Array([1, 2, 3]), | |
| 118 | rpId: "example.com", | |
| 119 | allowCredentials: [{ type: "public-key", id: new Uint8Array([1, 2, 3]) }], | |
| 120 | extensions: {}, | |
| 121 | }, | |
| 122 | ]); | |
| 123 | const response = credential.response as AuthenticatorAssertionResponse; | |
| 124 | assert.instanceOf(credential, FakePublicKeyCredential); | |
| 125 | assert.instanceOf(response, FakeAssertionResponse); | |
| 126 | assert.strictEqual(credential.id, assertionData.credentialId); | |
| 127 | assert.deepStrictEqual(bytesOf(credential.rawId), [1, 2, 3]); | |
| 128 | assert.deepStrictEqual(bytesOf(response.signature), [6, 7]); | |
| 129 | assert.deepStrictEqual(bytesOf(response.userHandle), [8]); | |
| 130 | assert.deepStrictEqual( | |
| 131 | bytesOf(credential.getClientExtensionResults().prf?.results?.first as ArrayBuffer), | |
| 132 | [9], | |
| 133 | ); | |
| 134 | assert.deepStrictEqual(credential.toJSON(), { | |
| 135 | id: assertionData.credentialId, | |
| 136 | rawId: assertionData.credentialId, | |
| 137 | type: "public-key", | |
| 138 | response: { | |
| 139 | clientDataJSON: assertionData.clientDataJSON, | |
| 140 | authenticatorData: assertionData.authenticatorData, | |
| 141 | signature: assertionData.signature, | |
| 142 | userHandle: assertionData.userHandle, | |
| 143 | }, | |
| 144 | clientExtensionResults: { prf: { results: { first: base64Url([9]) } } }, | |
| 145 | }); | |
| 146 | }); | |
| 147 | ||
| 148 | it("returns registrations with their attestation and public key", async () => { | |
| 149 | const { invoke, credentials, FakeAttestationResponse } = install(() => ({ | |
| 150 | success: true, | |
| 151 | data: attestationData, | |
| 152 | })); | |
| 153 | ||
| 154 | const credential = (await credentials.create({ | |
| 155 | publicKey: creationOptions, | |
| 156 | })) as PublicKeyCredential; | |
| 157 | ||
| 158 | assert.strictEqual(invoke.mock.calls[0]?.[0], PASSKEY_CREATE_CHANNEL); | |
| 159 | const response = credential.response as AuthenticatorAttestationResponse; | |
| 160 | assert.instanceOf(response, FakeAttestationResponse); | |
| 161 | assert.isNull(credential.authenticatorAttachment); | |
| 162 | assert.deepStrictEqual(bytesOf(response.attestationObject), [10]); | |
| 163 | assert.deepStrictEqual(bytesOf(response.getAuthenticatorData()), [11]); | |
| 164 | assert.deepStrictEqual(bytesOf(response.getPublicKey()), [12]); | |
| 165 | assert.strictEqual(response.getPublicKeyAlgorithm(), -7); | |
| 166 | assert.deepStrictEqual(credential.getClientExtensionResults(), { credProps: { rk: true } }); | |
| 167 | assert.deepStrictEqual(credential.toJSON(), { | |
| 168 | id: attestationData.credentialId, | |
| 169 | rawId: attestationData.credentialId, | |
| 170 | type: "public-key", | |
| 171 | response: { | |
| 172 | clientDataJSON: attestationData.clientDataJSON, | |
| 173 | attestationObject: attestationData.attestationObject, | |
| 174 | authenticatorData: attestationData.authData, | |
| 175 | transports: [], | |
| 176 | publicKey: attestationData.publicKey, | |
| 177 | publicKeyAlgorithm: -7, | |
| 178 | }, | |
| 179 | clientExtensionResults: { credProps: { rk: true } }, | |
| 180 | }); | |
| 181 | }); | |
| 182 | ||
| 183 | it("leaves autofill and non-passkey requests to Chromium", async () => { | |
| 184 | const { invoke, credentials, nativeCreate, nativeGet } = install(() => ({ | |
| 185 | success: true, | |
| 186 | data: assertionData, | |
| 187 | })); | |
| 188 | ||
| 189 | await credentials.get({ | |
| 190 | mediation: "conditional", | |
| 191 | publicKey: { challenge: new Uint8Array([1]) }, | |
| 192 | }); | |
| 193 | await credentials.get({ password: true } as CredentialRequestOptions); | |
| 194 | // Conditional create upgrades a password sign-in silently; no modal sheet. | |
| 195 | const upgrade = { mediation: "conditional", publicKey: creationOptions } as const; | |
| 196 | await credentials.create(upgrade); | |
| 197 | await credentials.create({ password: {} } as CredentialCreationOptions); | |
| 198 | assert.strictEqual(invoke.mock.calls.length, 0); | |
| 199 | assert.strictEqual(nativeGet.mock.calls.length, 2); | |
| 200 | assert.strictEqual(nativeCreate.mock.calls.length, 2); | |
| 201 | assert.isFalse(await PublicKeyCredential.isConditionalMediationAvailable()); | |
| 202 | assert.isTrue(await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()); | |
| 203 | }); | |
| 204 | ||
| 205 | it("rejects with the WebAuthn error the ceremony ended with", async () => { | |
| 206 | const { credentials } = install((channel) => ({ | |
| 207 | success: false, | |
| 208 | error: channel === PASSKEY_CREATE_CHANNEL ? "InvalidStateError" : "TypeError", | |
| 209 | })); | |
| 210 | const created = await credentials | |
| 211 | .create({ publicKey: creationOptions }) | |
| 212 | .catch((error: unknown) => error); | |
| 213 | assert.instanceOf(created, DOMException); | |
| 214 | assert.strictEqual((created as DOMException).name, "InvalidStateError"); | |
| 215 | const fetched = await credentials | |
| 216 | .get({ publicKey: { challenge: new Uint8Array([1]) } }) | |
| 217 | .catch((error: unknown) => error); | |
| 218 | assert.instanceOf(fetched, TypeError); | |
| 219 | }); | |
| 220 | ||
| 221 | it("refuses when the main process has no handler for the page", async () => { | |
| 222 | const { credentials } = install(() => { | |
| 223 | throw new Error("Error invoking remote method 'preview:passkey-get': No handler registered"); | |
| 224 | }); | |
| 225 | ||
| 226 | const error = await credentials | |
| 227 | .get({ publicKey: { challenge: new Uint8Array([1]) } }) | |
| 228 | .catch((caught: unknown) => caught); | |
| 229 | assert.instanceOf(error, DOMException); | |
| 230 | assert.strictEqual((error as DOMException).name, "NotAllowedError"); | |
| 231 | }); | |
| 232 | ||
| 233 | it("rejects as soon as the page aborts, without waiting on the system sheet", async () => { | |
| 234 | const { credentials } = install(() => new Promise(() => {})); | |
| 235 | const controller = new AbortController(); | |
| 236 | ||
| 237 | const pending = credentials | |
| 238 | .get({ publicKey: { challenge: new Uint8Array([1]) }, signal: controller.signal }) | |
| 239 | .catch((error: unknown) => error); | |
| 240 | controller.abort(); | |
| 241 | ||
| 242 | const error = await pending; | |
| 243 | assert.instanceOf(error, DOMException); | |
| 244 | assert.strictEqual((error as DOMException).name, "AbortError"); | |
| 245 | }); | |
| 246 | }); |
apps/desktop/src/preview/PasskeyBridge.ts created+286| ... | ... | @@ -0,0 +1,286 @@ |
| 1 | import type { CreateCredentialResult, GetCredentialResult } from "electron-webauthn"; | |
| 2 | ||
| 3 | import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; | |
| 4 | ||
| 5 | /** What the main process answers a guest passkey ceremony with. */ | |
| 6 | export type PasskeyCeremonyResult = | |
| 7 | | Extract<CreateCredentialResult, { success: true }> | |
| 8 | | Extract<GetCredentialResult, { success: true }> | |
| 9 | | { readonly success: false; readonly error: string }; | |
| 10 | ||
| 11 | type PasskeyChannel = typeof PASSKEY_CREATE_CHANNEL | typeof PASSKEY_GET_CHANNEL; | |
| 12 | // Credential Management Level 1 added `mediation` to creation requests. | |
| 13 | type CreationOptions = CredentialCreationOptions & { | |
| 14 | readonly mediation?: CredentialMediationRequirement; | |
| 15 | }; | |
| 16 | type InvokePasskeyCeremony = (channel: PasskeyChannel, publicKey: unknown) => Promise<unknown>; | |
| 17 | ||
| 18 | const NOT_ALLOWED_MESSAGE = | |
| 19 | "The operation either timed out or was not allowed. See: https://www.w3.org/TR/webauthn-2/#sctn-privacy-considerations-client."; | |
| 20 | const DOM_EXCEPTION_NAMES = new Set([ | |
| 21 | "AbortError", | |
| 22 | "InvalidStateError", | |
| 23 | "NotAllowedError", | |
| 24 | "NotSupportedError", | |
| 25 | "SecurityError", | |
| 26 | ]); | |
| 27 | ||
| 28 | const fromBase64Url = (value: string) => { | |
| 29 | const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); | |
| 30 | const binary = atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=")); | |
| 31 | return Uint8Array.from(binary, (character) => character.charCodeAt(0)).buffer; | |
| 32 | }; | |
| 33 | ||
| 34 | const toBase64Url = (buffer: ArrayBuffer) => | |
| 35 | btoa(String.fromCharCode(...new Uint8Array(buffer))) | |
| 36 | .replaceAll("+", "-") | |
| 37 | .replaceAll("/", "_") | |
| 38 | .replace(/=+$/u, ""); | |
| 39 | ||
| 40 | const ceremonyError = (name: string) => | |
| 41 | name === "TypeError" | |
| 42 | ? new TypeError("The passkey request options are invalid.") | |
| 43 | : new DOMException( | |
| 44 | NOT_ALLOWED_MESSAGE, | |
| 45 | DOM_EXCEPTION_NAMES.has(name) ? name : "NotAllowedError", | |
| 46 | ); | |
| 47 | ||
| 48 | /** | |
| 49 | * Copies request options into plain IPC-cloneable data. Chromium ignores | |
| 50 | * members it does not know, so functions and other uncloneable values drop out | |
| 51 | * here instead of failing the whole ceremony. | |
| 52 | */ | |
| 53 | const toCloneable = (value: unknown): unknown => { | |
| 54 | if (value instanceof ArrayBuffer) return value.slice(0); | |
| 55 | if (ArrayBuffer.isView(value)) { | |
| 56 | return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); | |
| 57 | } | |
| 58 | if (Array.isArray(value)) return value.map(toCloneable); | |
| 59 | if (typeof value === "object" && value !== null) { | |
| 60 | return Object.fromEntries( | |
| 61 | Object.entries(value).flatMap(([key, member]) => | |
| 62 | typeof member === "function" || member === undefined ? [] : [[key, toCloneable(member)]], | |
| 63 | ), | |
| 64 | ); | |
| 65 | } | |
| 66 | return value; | |
| 67 | }; | |
| 68 | ||
| 69 | // The objects below borrow the native prototypes so `instanceof` checks pass. | |
| 70 | // Their own properties shadow the native getters, which only work on objects | |
| 71 | // Chromium created itself. | |
| 72 | const withValues = <T extends object>(target: T, values: Record<string, unknown>) => { | |
| 73 | for (const [key, value] of Object.entries(values)) { | |
| 74 | Object.defineProperty(target, key, { value, enumerable: true, configurable: true }); | |
| 75 | } | |
| 76 | return target; | |
| 77 | }; | |
| 78 | ||
| 79 | const prfOutputs = (prf: { | |
| 80 | readonly enabled?: boolean; | |
| 81 | readonly results?: { readonly first?: string; readonly second?: string }; | |
| 82 | }): AuthenticationExtensionsPRFOutputs => { | |
| 83 | const first = prf.results?.first; | |
| 84 | const second = prf.results?.second; | |
| 85 | return { | |
| 86 | ...(prf.enabled === undefined ? {} : { enabled: prf.enabled }), | |
| 87 | ...(first === undefined | |
| 88 | ? {} | |
| 89 | : { | |
| 90 | results: { | |
| 91 | first: fromBase64Url(first), | |
| 92 | ...(second === undefined ? {} : { second: fromBase64Url(second) }), | |
| 93 | }, | |
| 94 | }), | |
| 95 | }; | |
| 96 | }; | |
| 97 | ||
| 98 | const extensionResultsJson = (results: AuthenticationExtensionsClientOutputs) => | |
| 99 | JSON.parse( | |
| 100 | JSON.stringify(results, (_key, value: unknown) => | |
| 101 | value instanceof ArrayBuffer ? toBase64Url(value) : value, | |
| 102 | ), | |
| 103 | ) as unknown; | |
| 104 | ||
| 105 | const makeCredential = (input: { | |
| 106 | readonly id: string; | |
| 107 | readonly authenticatorAttachment: AuthenticatorAttachment | null; | |
| 108 | readonly response: AuthenticatorResponse; | |
| 109 | readonly responseJson: Record<string, unknown>; | |
| 110 | readonly extensionResults: AuthenticationExtensionsClientOutputs; | |
| 111 | }) => | |
| 112 | withValues(Object.create(PublicKeyCredential.prototype) as PublicKeyCredential, { | |
| 113 | id: input.id, | |
| 114 | rawId: fromBase64Url(input.id), | |
| 115 | type: "public-key", | |
| 116 | authenticatorAttachment: input.authenticatorAttachment, | |
| 117 | response: input.response, | |
| 118 | getClientExtensionResults: () => input.extensionResults, | |
| 119 | toJSON: () => ({ | |
| 120 | id: input.id, | |
| 121 | rawId: input.id, | |
| 122 | type: "public-key", | |
| 123 | ...(input.authenticatorAttachment === null | |
| 124 | ? {} | |
| 125 | : { authenticatorAttachment: input.authenticatorAttachment }), | |
| 126 | response: input.responseJson, | |
| 127 | clientExtensionResults: extensionResultsJson(input.extensionResults), | |
| 128 | }), | |
| 129 | }); | |
| 130 | ||
| 131 | const credentialFromCreateResult = ( | |
| 132 | data: Extract<CreateCredentialResult, { success: true }>["data"], | |
| 133 | ) => { | |
| 134 | const transports = [...data.transports]; | |
| 135 | const publicKey = data.publicKey.length > 0 ? data.publicKey : null; | |
| 136 | const extensionResults: AuthenticationExtensionsClientOutputs = { | |
| 137 | ...(data.extensions.credProps ? { credProps: { ...data.extensions.credProps } } : {}), | |
| 138 | ...(data.extensions.prf ? { prf: prfOutputs(data.extensions.prf) } : {}), | |
| 139 | ...(data.extensions.largeBlob ? { largeBlob: { ...data.extensions.largeBlob } } : {}), | |
| 140 | }; | |
| 141 | return makeCredential({ | |
| 142 | id: data.credentialId, | |
| 143 | // The native layer cannot tell a synced passkey from a security key. | |
| 144 | authenticatorAttachment: null, | |
| 145 | response: withValues( | |
| 146 | Object.create(AuthenticatorAttestationResponse.prototype) as AuthenticatorAttestationResponse, | |
| 147 | { | |
| 148 | clientDataJSON: fromBase64Url(data.clientDataJSON), | |
| 149 | attestationObject: fromBase64Url(data.attestationObject), | |
| 150 | getTransports: () => [...transports], | |
| 151 | getAuthenticatorData: () => fromBase64Url(data.authData), | |
| 152 | getPublicKey: () => (publicKey === null ? null : fromBase64Url(publicKey)), | |
| 153 | getPublicKeyAlgorithm: () => data.publicKeyAlgorithm, | |
| 154 | }, | |
| 155 | ), | |
| 156 | responseJson: { | |
| 157 | clientDataJSON: data.clientDataJSON, | |
| 158 | attestationObject: data.attestationObject, | |
| 159 | authenticatorData: data.authData, | |
| 160 | transports, | |
| 161 | ...(publicKey === null ? {} : { publicKey }), | |
| 162 | publicKeyAlgorithm: data.publicKeyAlgorithm, | |
| 163 | }, | |
| 164 | extensionResults, | |
| 165 | }); | |
| 166 | }; | |
| 167 | ||
| 168 | const credentialFromGetResult = (data: Extract<GetCredentialResult, { success: true }>["data"]) => { | |
| 169 | const userHandle = data.userHandle.length > 0 ? data.userHandle : null; | |
| 170 | const { prf, largeBlob } = data.extensions ?? {}; | |
| 171 | const extensionResults: AuthenticationExtensionsClientOutputs = { | |
| 172 | ...(prf ? { prf: prfOutputs(prf) } : {}), | |
| 173 | ...(largeBlob | |
| 174 | ? { | |
| 175 | largeBlob: { | |
| 176 | ...(largeBlob.blob === undefined ? {} : { blob: fromBase64Url(largeBlob.blob) }), | |
| 177 | ...(largeBlob.written === undefined ? {} : { written: largeBlob.written }), | |
| 178 | }, | |
| 179 | } | |
| 180 | : {}), | |
| 181 | }; | |
| 182 | return makeCredential({ | |
| 183 | id: data.credentialId, | |
| 184 | authenticatorAttachment: null, | |
| 185 | response: withValues( | |
| 186 | Object.create(AuthenticatorAssertionResponse.prototype) as AuthenticatorAssertionResponse, | |
| 187 | { | |
| 188 | clientDataJSON: fromBase64Url(data.clientDataJSON), | |
| 189 | authenticatorData: fromBase64Url(data.authenticatorData), | |
| 190 | signature: fromBase64Url(data.signature), | |
| 191 | userHandle: userHandle === null ? null : fromBase64Url(userHandle), | |
| 192 | }, | |
| 193 | ), | |
| 194 | responseJson: { | |
| 195 | clientDataJSON: data.clientDataJSON, | |
| 196 | authenticatorData: data.authenticatorData, | |
| 197 | signature: data.signature, | |
| 198 | ...(userHandle === null ? {} : { userHandle }), | |
| 199 | }, | |
| 200 | extensionResults, | |
| 201 | }); | |
| 202 | }; | |
| 203 | ||
| 204 | const abortReason = (signal: AbortSignal) => | |
| 205 | signal.reason ?? new DOMException("The operation was aborted.", "AbortError"); | |
| 206 | ||
| 207 | /** | |
| 208 | * Routes this page's WebAuthn ceremonies to the system passkey sheet through | |
| 209 | * the main process, which supplies the frame's real origin. Runs in the page's | |
| 210 | * own world (the preview preload has contextIsolation off), before any page | |
| 211 | * script. Conditional (autofill and automatic upgrade) requests stay native: | |
| 212 | * the system sheet is modal and must not open on its own. | |
| 213 | */ | |
| 214 | export function installPasskeyBridge(invoke: InvokePasskeyCeremony) { | |
| 215 | // Insecure contexts have no WebAuthn to bridge. | |
| 216 | if (typeof CredentialsContainer === "undefined" || typeof PublicKeyCredential === "undefined") { | |
| 217 | return; | |
| 218 | } | |
| 219 | const run = async ( | |
| 220 | channel: PasskeyChannel, | |
| 221 | publicKey: unknown, | |
| 222 | signal: AbortSignal | undefined, | |
| 223 | ): Promise<PublicKeyCredential> => { | |
| 224 | if (signal?.aborted) throw abortReason(signal); | |
| 225 | const ceremony = invoke(channel, toCloneable(publicKey)).then( | |
| 226 | (value) => { | |
| 227 | const result = value as PasskeyCeremonyResult; | |
| 228 | if (!result.success) throw ceremonyError(result.error); | |
| 229 | return "attestationObject" in result.data | |
| 230 | ? credentialFromCreateResult(result.data) | |
| 231 | : credentialFromGetResult(result.data); | |
| 232 | }, | |
| 233 | // No handler yet (or any more) for this guest: answer like a refusal. | |
| 234 | (error: unknown) => { | |
| 235 | throw error instanceof DOMException || error instanceof TypeError | |
| 236 | ? error | |
| 237 | : ceremonyError("NotAllowedError"); | |
| 238 | }, | |
| 239 | ); | |
| 240 | if (!signal) return ceremony; | |
| 241 | return new Promise((resolve, reject) => { | |
| 242 | const onAbort = () => reject(abortReason(signal)); | |
| 243 | signal.addEventListener("abort", onAbort, { once: true }); | |
| 244 | ceremony.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort)); | |
| 245 | }); | |
| 246 | }; | |
| 247 | ||
| 248 | const container = CredentialsContainer.prototype; | |
| 249 | const nativeCreate = container.create; | |
| 250 | const nativeGet = container.get; | |
| 251 | const define = (target: object, key: string, value: unknown) => | |
| 252 | Object.defineProperty(target, key, { value, configurable: true, writable: true }); | |
| 253 | ||
| 254 | define( | |
| 255 | container, | |
| 256 | "create", | |
| 257 | function create(this: CredentialsContainer, options?: CreationOptions) { | |
| 258 | return options?.publicKey && options.mediation !== "conditional" | |
| 259 | ? run(PASSKEY_CREATE_CHANNEL, options.publicKey, options.signal) | |
| 260 | : Reflect.apply(nativeCreate, this, [options]); | |
| 261 | }, | |
| 262 | ); | |
| 263 | define( | |
| 264 | container, | |
| 265 | "get", | |
| 266 | function get(this: CredentialsContainer, options?: CredentialRequestOptions) { | |
| 267 | return options?.publicKey && options.mediation !== "conditional" | |
| 268 | ? run(PASSKEY_GET_CHANNEL, options.publicKey, options.signal) | |
| 269 | : Reflect.apply(nativeGet, this, [options]); | |
| 270 | }, | |
| 271 | ); | |
| 272 | ||
| 273 | define(PublicKeyCredential, "isUserVerifyingPlatformAuthenticatorAvailable", async () => true); | |
| 274 | define(PublicKeyCredential, "isConditionalMediationAvailable", async () => false); | |
| 275 | const nativeCapabilities = PublicKeyCredential.getClientCapabilities; | |
| 276 | if (typeof nativeCapabilities === "function") { | |
| 277 | define(PublicKeyCredential, "getClientCapabilities", async () => ({ | |
| 278 | ...(await Reflect.apply(nativeCapabilities, PublicKeyCredential, [])), | |
| 279 | conditionalCreate: false, | |
| 280 | conditionalGet: false, | |
| 281 | hybridTransport: true, | |
| 282 | passkeyPlatformAuthenticator: true, | |
| 283 | userVerifyingPlatformAuthenticator: true, | |
| 284 | })); | |
| 285 | } | |
| 286 | } |
apps/desktop/src/preview/Passkeys.test.ts created+374| ... | ... | @@ -0,0 +1,374 @@ |
| 1 | import { assert, describe, it } from "@effect/vitest"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Fiber from "effect/Fiber"; | |
| 4 | import * as FileSystem from "effect/FileSystem"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import { afterEach, beforeEach, vi } from "vite-plus/test"; | |
| 7 | ||
| 8 | const electron = vi.hoisted(() => ({ | |
| 9 | configureWebAuthn: vi.fn(), | |
| 10 | showMessageBox: vi.fn(), | |
| 11 | fromWebContents: vi.fn(), | |
| 12 | })); | |
| 13 | const webauthn = vi.hoisted(() => ({ createCredential: vi.fn(), getCredential: vi.fn() })); | |
| 14 | ||
| 15 | vi.mock("electron", () => ({ | |
| 16 | app: { configureWebAuthn: electron.configureWebAuthn }, | |
| 17 | dialog: { showMessageBox: electron.showMessageBox }, | |
| 18 | BrowserWindow: { fromWebContents: electron.fromWebContents }, | |
| 19 | webContents: { fromFrame: () => undefined }, | |
| 20 | })); | |
| 21 | vi.mock("electron-webauthn", () => webauthn); | |
| 22 | ||
| 23 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 24 | import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; | |
| 25 | import * as PreviewPasskeys from "./Passkeys.ts"; | |
| 26 | ||
| 27 | const layerFor = (packageJson: object, isPackaged = true) => | |
| 28 | PreviewPasskeys.layer.pipe( | |
| 29 | Layer.provide( | |
| 30 | Layer.succeed( | |
| 31 | DesktopEnvironment.DesktopEnvironment, | |
| 32 | DesktopEnvironment.DesktopEnvironment.of({ | |
| 33 | platform: "darwin", | |
| 34 | isPackaged, | |
| 35 | appRoot: "/app", | |
| 36 | path: { join: (...parts: ReadonlyArray<string>) => parts.join("/") }, | |
| 37 | } as DesktopEnvironment.DesktopEnvironment["Service"]), | |
| 38 | ), | |
| 39 | ), | |
| 40 | Layer.provide( | |
| 41 | FileSystem.layerNoop({ | |
| 42 | readFileString: () => Effect.succeed(JSON.stringify(packageJson)), | |
| 43 | }), | |
| 44 | ), | |
| 45 | ); | |
| 46 | ||
| 47 | type Handler = (event: { readonly senderFrame: object | null }, publicKey: unknown) => unknown; | |
| 48 | ||
| 49 | const makeGuest = (origin = "https://accounts.example.com") => { | |
| 50 | const handlers = new Map<string, Handler>(); | |
| 51 | const state = { focused: true }; | |
| 52 | const mainFrame = { origin, isDestroyed: () => false }; | |
| 53 | const guest = { | |
| 54 | mainFrame, | |
| 55 | hostWebContents: {}, | |
| 56 | isFocused: () => state.focused, | |
| 57 | ipc: { | |
| 58 | handle: (channel: string, handler: Handler) => handlers.set(channel, handler), | |
| 59 | removeHandler: (channel: string) => handlers.delete(channel), | |
| 60 | }, | |
| 61 | }; | |
| 62 | const call = (channel: string, publicKey: unknown, senderFrame: object | null = mainFrame) => | |
| 63 | Effect.promise(async () => handlers.get(channel)?.({ senderFrame }, publicKey)); | |
| 64 | return { guest: guest as unknown as Electron.WebContents, mainFrame, handlers, state, call }; | |
| 65 | }; | |
| 66 | ||
| 67 | const bridgeLayer = layerFor({ t3codeWebAuthn: { browserPasskeys: true } }); | |
| 68 | ||
| 69 | /** CBOR `{ fmt: "packed", attStmt: { alg: -7, sig }, authData }`, as authenticators encode it. */ | |
| 70 | const attestationObject = (authData: ReadonlyArray<number>) => { | |
| 71 | const text = (value: string) => [0x60 + value.length, ...Buffer.from(value)]; | |
| 72 | const bytes = (value: ReadonlyArray<number>) => [0x58, value.length, ...value]; | |
| 73 | return Buffer.from([ | |
| 74 | 0xa3, | |
| 75 | ...text("fmt"), | |
| 76 | ...text("packed"), | |
| 77 | ...text("attStmt"), | |
| 78 | 0xa2, | |
| 79 | ...text("alg"), | |
| 80 | 0x26, | |
| 81 | ...text("sig"), | |
| 82 | ...bytes(Array.from({ length: 70 }, () => 7)), | |
| 83 | ...text("authData"), | |
| 84 | ...bytes(authData), | |
| 85 | ]).toString("base64url"); | |
| 86 | }; | |
| 87 | ||
| 88 | describe("PreviewPasskeys", () => { | |
| 89 | beforeEach(() => { | |
| 90 | vi.clearAllMocks(); | |
| 91 | electron.fromWebContents.mockReturnValue({ | |
| 92 | isDestroyed: () => false, | |
| 93 | getNativeWindowHandle: () => Buffer.from([1]), | |
| 94 | }); | |
| 95 | }); | |
| 96 | // Restored here so a failing fake-timer test cannot leak fake timers into the next one. | |
| 97 | afterEach(() => { | |
| 98 | vi.useRealTimers(); | |
| 99 | }); | |
| 100 | ||
| 101 | it.effect("pins each guest ceremony to the origin of the frame that asked", () => | |
| 102 | Effect.gen(function* () { | |
| 103 | const passkeys = yield* PreviewPasskeys.PreviewPasskeys; | |
| 104 | const { guest, handlers, call } = makeGuest(); | |
| 105 | webauthn.getCredential.mockResolvedValue({ | |
| 106 | success: false, | |
| 107 | error: "NotAllowedError", | |
| 108 | errorObject: new Error("native detail"), | |
| 109 | }); | |
| 110 | ||
| 111 | const detach = passkeys.attachGuest(guest); | |
| 112 | const publicKey = { challenge: new Uint8Array([1]), rpId: "example.com" }; | |
| 113 | const result = yield* call(PASSKEY_GET_CHANNEL, publicKey); | |
| 114 | ||
| 115 | assert.deepStrictEqual(result, { success: false, error: "NotAllowedError" }); | |
| 116 | const [sentPublicKey, options] = webauthn.getCredential.mock.calls[0] ?? []; | |
| 117 | assert.deepStrictEqual(sentPublicKey, publicKey); | |
| 118 | assert.strictEqual(options.currentOrigin, "https://accounts.example.com"); | |
| 119 | assert.strictEqual(options.topFrameOrigin, "https://accounts.example.com"); | |
| 120 | assert.isTrue(options.isPublicSuffix("com")); | |
| 121 | assert.isTrue(options.isPublicSuffix("github.io")); | |
| 122 | assert.isFalse(options.isPublicSuffix("example.com")); | |
| 123 | assert.isFalse(options.isPublicSuffix("localhost")); | |
| 124 | ||
| 125 | // WebAuthn defaults the RP ID to the caller's own host. | |
| 126 | yield* call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]) }); | |
| 127 | assert.strictEqual(webauthn.getCredential.mock.calls[1]?.[0].rpId, "accounts.example.com"); | |
| 128 | ||
| 129 | // Only the main frame runs the preload; anything else is not the page. | |
| 130 | const forged = yield* call(PASSKEY_CREATE_CHANNEL, publicKey, { | |
| 131 | origin: "https://evil.example", | |
| 132 | }); | |
| 133 | assert.deepStrictEqual(forged, { success: false, error: "NotAllowedError" }); | |
| 134 | assert.strictEqual(webauthn.createCredential.mock.calls.length, 0); | |
| 135 | ||
| 136 | detach(); | |
| 137 | assert.strictEqual(handlers.size, 0); | |
| 138 | }).pipe(Effect.provide(bridgeLayer)), | |
| 139 | ); | |
| 140 | ||
| 141 | it.effect("registers ES256 keys only and reports their real authenticator data", () => | |
| 142 | Effect.gen(function* () { | |
| 143 | const passkeys = yield* PreviewPasskeys.PreviewPasskeys; | |
| 144 | const { guest, call } = makeGuest(); | |
| 145 | passkeys.attachGuest(guest); | |
| 146 | webauthn.createCredential.mockResolvedValue({ | |
| 147 | success: true, | |
| 148 | data: { | |
| 149 | credentialId: "AQ", | |
| 150 | clientDataJSON: "Ag", | |
| 151 | attestationObject: attestationObject([1, 2, 3, 4]), | |
| 152 | authData: Buffer.from('{"parsed":true}').toString("base64url"), | |
| 153 | publicKey: "BQ", | |
| 154 | publicKeyAlgorithm: -7, | |
| 155 | transports: ["hybrid", "internal"], | |
| 156 | extensions: {}, | |
| 157 | }, | |
| 158 | }); | |
| 159 | const publicKey = { | |
| 160 | challenge: new Uint8Array([1]), | |
| 161 | pubKeyCredParams: [ | |
| 162 | { type: "public-key", alg: -8 }, | |
| 163 | { type: "public-key", alg: -7 }, | |
| 164 | { type: "public-key", alg: -257 }, | |
| 165 | ], | |
| 166 | }; | |
| 167 | ||
| 168 | const result = yield* call(PASSKEY_CREATE_CHANNEL, publicKey); | |
| 169 | ||
| 170 | assert.deepStrictEqual(webauthn.createCredential.mock.calls[0]?.[0].pubKeyCredParams, [ | |
| 171 | { type: "public-key", alg: -7 }, | |
| 172 | ]); | |
| 173 | assert.deepInclude(result, { success: true }); | |
| 174 | const data = (result as { readonly data: Record<string, unknown> }).data; | |
| 175 | assert.strictEqual(data.authData, Buffer.from([1, 2, 3, 4]).toString("base64url")); | |
| 176 | assert.deepStrictEqual(data.transports, []); | |
| 177 | ||
| 178 | const unsupported = yield* call(PASSKEY_CREATE_CHANNEL, { | |
| 179 | ...publicKey, | |
| 180 | pubKeyCredParams: [{ type: "public-key", alg: -8 }], | |
| 181 | }); | |
| 182 | assert.deepStrictEqual(unsupported, { success: false, error: "NotSupportedError" }); | |
| 183 | assert.strictEqual(webauthn.createCredential.mock.calls.length, 1); | |
| 184 | ||
| 185 | // A malformed algorithm list is the page's error, not a cue to pick ES256. | |
| 186 | const malformed = yield* call(PASSKEY_CREATE_CHANNEL, { | |
| 187 | ...publicKey, | |
| 188 | pubKeyCredParams: { type: "public-key", alg: -7 }, | |
| 189 | }); | |
| 190 | assert.deepStrictEqual(malformed, { success: false, error: "TypeError" }); | |
| 191 | assert.strictEqual(webauthn.createCredential.mock.calls.length, 1); | |
| 192 | ||
| 193 | // Without authenticator data the site cannot verify the passkey. | |
| 194 | webauthn.createCredential.mockResolvedValueOnce({ | |
| 195 | success: true, | |
| 196 | data: { credentialId: "AQ", attestationObject: Buffer.from([0x00]).toString("base64url") }, | |
| 197 | }); | |
| 198 | assert.deepStrictEqual(yield* call(PASSKEY_CREATE_CHANNEL, publicKey), { | |
| 199 | success: false, | |
| 200 | error: "NotAllowedError", | |
| 201 | }); | |
| 202 | }).pipe(Effect.provide(bridgeLayer)), | |
| 203 | ); | |
| 204 | ||
| 205 | it.effect("answers only with a credential the site allowed", () => | |
| 206 | Effect.gen(function* () { | |
| 207 | const passkeys = yield* PreviewPasskeys.PreviewPasskeys; | |
| 208 | const { guest, call } = makeGuest(); | |
| 209 | passkeys.attachGuest(guest); | |
| 210 | const assertion = (credentialId: string) => ({ | |
| 211 | success: true, | |
| 212 | data: { | |
| 213 | credentialId, | |
| 214 | clientDataJSON: "Ag", | |
| 215 | authenticatorData: "Aw", | |
| 216 | signature: "BA", | |
| 217 | userHandle: "BQ", | |
| 218 | }, | |
| 219 | }); | |
| 220 | const publicKey = { | |
| 221 | challenge: new Uint8Array([1]), | |
| 222 | rpId: "example.com", | |
| 223 | allowCredentials: [{ type: "public-key", id: new Uint8Array([1]) }], | |
| 224 | }; | |
| 225 | ||
| 226 | // macOS applies the allow list to passkeys but not to security keys. | |
| 227 | webauthn.getCredential.mockResolvedValueOnce(assertion("Ag")); | |
| 228 | const other = yield* call(PASSKEY_GET_CHANNEL, publicKey); | |
| 229 | assert.deepStrictEqual(other, { success: false, error: "NotAllowedError" }); | |
| 230 | ||
| 231 | webauthn.getCredential.mockResolvedValueOnce(assertion("AQ")); | |
| 232 | assert.deepInclude(yield* call(PASSKEY_GET_CHANNEL, publicKey), { success: true }); | |
| 233 | ||
| 234 | // With no allow list, any of the site's credentials will do. | |
| 235 | webauthn.getCredential.mockResolvedValueOnce(assertion("Ag")); | |
| 236 | const discoverable = yield* call(PASSKEY_GET_CHANNEL, { | |
| 237 | challenge: new Uint8Array([1]), | |
| 238 | rpId: "example.com", | |
| 239 | }); | |
| 240 | assert.deepInclude(discoverable, { success: true }); | |
| 241 | }).pipe(Effect.provide(bridgeLayer)), | |
| 242 | ); | |
| 243 | ||
| 244 | it.effect("opens the sheet only for a focused, secure page with nothing else pending", () => | |
| 245 | Effect.gen(function* () { | |
| 246 | const passkeys = yield* PreviewPasskeys.PreviewPasskeys; | |
| 247 | const notAllowed = { success: false, error: "NotAllowedError" }; | |
| 248 | const publicKey = { challenge: new Uint8Array([1]), rpId: "example.com" }; | |
| 249 | ||
| 250 | const lan = makeGuest("http://192.168.1.5:3000"); | |
| 251 | passkeys.attachGuest(lan.guest); | |
| 252 | assert.deepStrictEqual(yield* lan.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); | |
| 253 | // macOS refuses `*.localhost`, so the page hears no rather than a native error. | |
| 254 | const subdomain = makeGuest("http://app.localhost:3000"); | |
| 255 | passkeys.attachGuest(subdomain.guest); | |
| 256 | assert.deepStrictEqual(yield* subdomain.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); | |
| 257 | ||
| 258 | const page = makeGuest(); | |
| 259 | passkeys.attachGuest(page.guest); | |
| 260 | page.state.focused = false; | |
| 261 | assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); | |
| 262 | assert.strictEqual(webauthn.getCredential.mock.calls.length, 0); | |
| 263 | ||
| 264 | page.state.focused = true; | |
| 265 | let finish: (value: unknown) => void = () => {}; | |
| 266 | webauthn.getCredential.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); | |
| 267 | const first = yield* Effect.forkChild(page.call(PASSKEY_GET_CHANNEL, publicKey)); | |
| 268 | yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve))); | |
| 269 | assert.deepStrictEqual(yield* page.call(PASSKEY_GET_CHANNEL, publicKey), notAllowed); | |
| 270 | ||
| 271 | // The page navigated while the sheet was up; its credential goes nowhere. | |
| 272 | page.mainFrame.origin = "https://other.example"; | |
| 273 | finish({ success: true, data: { credentialId: "AQ" } }); | |
| 274 | assert.deepStrictEqual(yield* Fiber.join(first), notAllowed); | |
| 275 | }).pipe(Effect.provide(bridgeLayer)), | |
| 276 | ); | |
| 277 | ||
| 278 | it.effect("answers the page even when the native ceremony never settles", () => | |
| 279 | Effect.gen(function* () { | |
| 280 | const passkeys = yield* PreviewPasskeys.PreviewPasskeys; | |
| 281 | const { guest, call } = makeGuest(); | |
| 282 | passkeys.attachGuest(guest); | |
| 283 | webauthn.getCredential.mockReturnValueOnce(new Promise(() => {})); | |
| 284 | vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); | |
| 285 | ||
| 286 | const pending = yield* Effect.forkChild( | |
| 287 | call(PASSKEY_GET_CHANNEL, { challenge: new Uint8Array([1]), timeout: 1_000 }), | |
| 288 | ); | |
| 289 | yield* Effect.promise(() => vi.advanceTimersByTimeAsync(6_000)); | |
| 290 | ||
| 291 | assert.deepStrictEqual(yield* Fiber.join(pending), { | |
| 292 | success: false, | |
| 293 | error: "NotAllowedError", | |
| 294 | }); | |
| 295 | }).pipe(Effect.provide(bridgeLayer)), | |
| 296 | ); | |
| 297 | ||
| 298 | it.effect("turns each path on only for builds signed for it", () => | |
| 299 | Effect.gen(function* () { | |
| 300 | const entitled = yield* PreviewPasskeys.PreviewPasskeys.pipe( | |
| 301 | Effect.provide( | |
| 302 | layerFor({ | |
| 303 | t3codeWebAuthn: { | |
| 304 | touchIdKeychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn", | |
| 305 | browserPasskeys: false, | |
| 306 | }, | |
| 307 | }), | |
| 308 | ), | |
| 309 | ); | |
| 310 | yield* entitled.configure; | |
| 311 | assert.deepStrictEqual(electron.configureWebAuthn.mock.calls, [ | |
| 312 | [{ touchID: { keychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn" } }], | |
| 313 | ]); | |
| 314 | assert.isFalse(entitled.bridgeEnabled); | |
| 315 | const { guest, handlers } = makeGuest(); | |
| 316 | entitled.attachGuest(guest); | |
| 317 | assert.strictEqual(handlers.size, 0); | |
| 318 | ||
| 319 | const unpackaged = yield* PreviewPasskeys.PreviewPasskeys.pipe( | |
| 320 | Effect.provide( | |
| 321 | layerFor( | |
| 322 | { t3codeWebAuthn: { touchIdKeychainAccessGroup: "X", browserPasskeys: true } }, | |
| 323 | false, | |
| 324 | ), | |
| 325 | ), | |
| 326 | ); | |
| 327 | yield* unpackaged.configure; | |
| 328 | assert.strictEqual(electron.configureWebAuthn.mock.calls.length, 1); | |
| 329 | assert.isFalse(unpackaged.bridgeEnabled); | |
| 330 | }), | |
| 331 | ); | |
| 332 | ||
| 333 | it.effect("asks which passkey to use only when a site has several", () => | |
| 334 | Effect.gen(function* () { | |
| 335 | const passkeys = yield* PreviewPasskeys.PreviewPasskeys; | |
| 336 | const listeners: Array<(...args: ReadonlyArray<unknown>) => void> = []; | |
| 337 | const session = { | |
| 338 | on: (_event: string, listener: (...args: ReadonlyArray<unknown>) => void) => | |
| 339 | listeners.push(listener), | |
| 340 | } as unknown as Electron.Session; | |
| 341 | passkeys.installSessionHandlers(session); | |
| 342 | passkeys.installSessionHandlers(session); | |
| 343 | assert.strictEqual(listeners.length, 1); | |
| 344 | ||
| 345 | const select = (accounts: ReadonlyArray<Electron.WebAuthnAccount>) => | |
| 346 | Effect.promise( | |
| 347 | () => | |
| 348 | new Promise<unknown>((resolve) => | |
| 349 | listeners[0]?.( | |
| 350 | {}, | |
| 351 | { relyingPartyId: "example.com", accounts, frame: null }, | |
| 352 | (credentialId?: string) => resolve(credentialId), | |
| 353 | ), | |
| 354 | ), | |
| 355 | ); | |
| 356 | const alice = { credentialId: "alice", name: "alice@example.com" }; | |
| 357 | const bob = { credentialId: "bob", displayName: "Bob", name: "bob@example.com" }; | |
| 358 | ||
| 359 | assert.strictEqual(yield* select([alice]), "alice"); | |
| 360 | assert.strictEqual(electron.showMessageBox.mock.calls.length, 0); | |
| 361 | ||
| 362 | electron.showMessageBox.mockResolvedValueOnce({ response: 1 }); | |
| 363 | assert.strictEqual(yield* select([alice, bob]), "bob"); | |
| 364 | assert.deepStrictEqual(electron.showMessageBox.mock.calls[0]?.[0]?.buttons, [ | |
| 365 | "alice@example.com", | |
| 366 | "Bob (bob@example.com)", | |
| 367 | "Cancel", | |
| 368 | ]); | |
| 369 | ||
| 370 | electron.showMessageBox.mockResolvedValueOnce({ response: 2 }); | |
| 371 | assert.isUndefined(yield* select([alice, bob])); | |
| 372 | }).pipe(Effect.provide(layerFor({}))), | |
| 373 | ); | |
| 374 | }); |
apps/desktop/src/preview/Passkeys.ts created+242| ... | ... | @@ -0,0 +1,242 @@ |
| 1 | // @effect-diagnostics globalTimers:off - Ceremonies settle inside IPC handlers, outside an Effect runtime. | |
| 2 | import type { IpcMainInvokeEvent, Session, WebContents, WebFrameMain } from "electron"; | |
| 3 | import { app, BrowserWindow, dialog, webContents as electronWebContents } from "electron"; | |
| 4 | import * as Context from "effect/Context"; | |
| 5 | import * as Effect from "effect/Effect"; | |
| 6 | import * as FileSystem from "effect/FileSystem"; | |
| 7 | import * as Layer from "effect/Layer"; | |
| 8 | import * as Schema from "effect/Schema"; | |
| 9 | ||
| 10 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; | |
| 11 | import { PASSKEY_CREATE_CHANNEL, PASSKEY_GET_CHANNEL } from "./GuestProtocol.ts"; | |
| 12 | import type { PasskeyBackend, PasskeyCeremonyContext } from "./PasskeyBackend.ts"; | |
| 13 | import { macPasskeyBackend } from "./PasskeyBackendMac.ts"; | |
| 14 | import type { PasskeyCeremonyResult } from "./PasskeyBridge.ts"; | |
| 15 | ||
| 16 | const PasskeyPackageMetadata = Schema.Struct({ | |
| 17 | t3codeWebAuthn: Schema.optional( | |
| 18 | Schema.Struct({ | |
| 19 | touchIdKeychainAccessGroup: Schema.optional(Schema.String), | |
| 20 | browserPasskeys: Schema.optional(Schema.Boolean), | |
| 21 | }), | |
| 22 | ), | |
| 23 | }); | |
| 24 | const decodePasskeyPackageMetadata = Schema.decodeEffect( | |
| 25 | Schema.fromJsonString(PasskeyPackageMetadata), | |
| 26 | ); | |
| 27 | ||
| 28 | export class PreviewPasskeysConfigureError extends Schema.TaggedError<PreviewPasskeysConfigureError>()( | |
| 29 | "PreviewPasskeysConfigureError", | |
| 30 | { | |
| 31 | keychainAccessGroup: Schema.String, | |
| 32 | cause: Schema.Defect(), | |
| 33 | }, | |
| 34 | ) { | |
| 35 | override get message(): string { | |
| 36 | return `Failed to enable Touch ID passkeys for the in-app browser (keychain group ${this.keychainAccessGroup}).`; | |
| 37 | } | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * Passkeys for in-app browser pages on macOS, where Electron has no WebAuthn UI | |
| 42 | * of its own. Signed builds record which entitlements their provisioning | |
| 43 | * profile granted (scripts/build-desktop-artifact.ts), and each path turns on | |
| 44 | * only when its entitlement is present: | |
| 45 | * | |
| 46 | * - Touch ID: Electron's built-in platform authenticator. Passkeys it creates | |
| 47 | * stay on this Mac, in T3 Code's keychain group. | |
| 48 | * - Browser passkeys: Apple's managed browser entitlement lets the system sheet | |
| 49 | * (iCloud Keychain, password managers, phones, security keys) serve any | |
| 50 | * site. The guest preload hands each ceremony to this process, which pins | |
| 51 | * the requesting frame's real origin. | |
| 52 | * | |
| 53 | * Windows needs neither: Chromium already uses Windows Hello there. | |
| 54 | */ | |
| 55 | export class PreviewPasskeys extends Context.Service< | |
| 56 | PreviewPasskeys, | |
| 57 | { | |
| 58 | /** Whether preview pages route WebAuthn through the system passkey sheet. */ | |
| 59 | readonly bridgeEnabled: boolean; | |
| 60 | /** Turns on Touch ID passkeys when this build is entitled to them. Runs after app ready. */ | |
| 61 | readonly configure: Effect.Effect<void>; | |
| 62 | /** Lets the user choose between several passkeys for one site. Idempotent. */ | |
| 63 | readonly installSessionHandlers: (session: Session) => void; | |
| 64 | /** Serves a preview guest's passkey ceremonies. Returns the detach function. */ | |
| 65 | readonly attachGuest: (guest: WebContents) => () => void; | |
| 66 | } | |
| 67 | >()("@t3tools/desktop/preview/Passkeys/PreviewPasskeys") {} | |
| 68 | ||
| 69 | const notAllowed: PasskeyCeremonyResult = { success: false, error: "NotAllowedError" }; | |
| 70 | ||
| 71 | const accountLabel = (account: Electron.WebAuthnAccount) => | |
| 72 | account.displayName && account.name && account.displayName !== account.name | |
| 73 | ? `${account.displayName} (${account.name})` | |
| 74 | : (account.name ?? account.displayName ?? "Unnamed passkey"); | |
| 75 | ||
| 76 | const ownerWindow = (frame: WebFrameMain | null) => { | |
| 77 | const contents = frame ? electronWebContents.fromFrame(frame) : undefined; | |
| 78 | const owner = contents?.hostWebContents ?? contents; | |
| 79 | return owner ? BrowserWindow.fromWebContents(owner) : null; | |
| 80 | }; | |
| 81 | ||
| 82 | const chooseAccount = async (details: Electron.SelectWebauthnAccountDetails) => { | |
| 83 | const [onlyAccount] = details.accounts; | |
| 84 | // The Touch ID prompt that follows already asks the user to confirm. | |
| 85 | if (details.accounts.length === 1) return onlyAccount?.credentialId; | |
| 86 | const options: Electron.MessageBoxOptions = { | |
| 87 | type: "none", | |
| 88 | message: "Choose a passkey", | |
| 89 | detail: details.relyingPartyId, | |
| 90 | buttons: [...details.accounts.map(accountLabel), "Cancel"], | |
| 91 | cancelId: details.accounts.length, | |
| 92 | defaultId: 0, | |
| 93 | noLink: true, | |
| 94 | }; | |
| 95 | const parent = ownerWindow(details.frame); | |
| 96 | const { response } = parent | |
| 97 | ? await dialog.showMessageBox(parent, options) | |
| 98 | : await dialog.showMessageBox(options); | |
| 99 | return details.accounts[response]?.credentialId; | |
| 100 | }; | |
| 101 | ||
| 102 | const NATIVE_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; | |
| 103 | const NATIVE_MAX_TIMEOUT_MS = 60 * 60 * 1000; | |
| 104 | ||
| 105 | // Mirrors the native layer's own clamp, plus a margin: it can leave a ceremony | |
| 106 | // unsettled, and the page must still get an answer. | |
| 107 | const ceremonyDeadline = (timeout: unknown) => | |
| 108 | (typeof timeout === "number" && timeout > 0 | |
| 109 | ? Math.min(timeout, NATIVE_MAX_TIMEOUT_MS) | |
| 110 | : NATIVE_DEFAULT_TIMEOUT_MS) + 5_000; | |
| 111 | ||
| 112 | const withDeadline = (ceremony: Promise<PasskeyCeremonyResult>, milliseconds: number) => { | |
| 113 | let timer: ReturnType<typeof setTimeout> | undefined; | |
| 114 | const deadline = new Promise<PasskeyCeremonyResult>((resolve) => { | |
| 115 | timer = setTimeout(() => resolve(notAllowed), milliseconds); | |
| 116 | }); | |
| 117 | return Promise.race([ceremony, deadline]).finally(() => clearTimeout(timer)); | |
| 118 | }; | |
| 119 | ||
| 120 | // Secure contexts only: https, or http on this machine. Chromium also trusts | |
| 121 | // `*.localhost`, but the macOS layer refuses it, so it is turned away here | |
| 122 | // instead of failing deeper with a less useful error. | |
| 123 | const isTrustworthyOrigin = (origin: string) => { | |
| 124 | if (!URL.canParse(origin)) return false; | |
| 125 | const { protocol, hostname } = new URL(origin); | |
| 126 | return ( | |
| 127 | protocol === "https:" || | |
| 128 | (protocol === "http:" && | |
| 129 | (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]")) | |
| 130 | ); | |
| 131 | }; | |
| 132 | ||
| 133 | /** | |
| 134 | * The platform that serves preview pages' passkey ceremonies, or none. Windows | |
| 135 | * needs none: Chromium already hands WebAuthn to Windows Hello there. | |
| 136 | */ | |
| 137 | const backendFor = (platform: NodeJS.Platform): PasskeyBackend | undefined => | |
| 138 | platform === "darwin" ? macPasskeyBackend : undefined; | |
| 139 | ||
| 140 | const make = Effect.gen(function* () { | |
| 141 | const environment = yield* DesktopEnvironment.DesktopEnvironment; | |
| 142 | const fileSystem = yield* FileSystem.FileSystem; | |
| 143 | ||
| 144 | const metadata = | |
| 145 | environment.platform === "darwin" && environment.isPackaged | |
| 146 | ? yield* fileSystem | |
| 147 | .readFileString(environment.path.join(environment.appRoot, "package.json")) | |
| 148 | .pipe( | |
| 149 | Effect.flatMap(decodePasskeyPackageMetadata), | |
| 150 | Effect.map((parsed) => parsed.t3codeWebAuthn), | |
| 151 | Effect.orElseSucceed(() => undefined), | |
| 152 | ) | |
| 153 | : undefined; | |
| 154 | const keychainAccessGroup = metadata?.touchIdKeychainAccessGroup; | |
| 155 | const backend = metadata?.browserPasskeys === true ? backendFor(environment.platform) : undefined; | |
| 156 | const bridgeEnabled = backend !== undefined; | |
| 157 | const sessionsWithHandlers = new WeakSet<Session>(); | |
| 158 | ||
| 159 | return PreviewPasskeys.of({ | |
| 160 | bridgeEnabled, | |
| 161 | configure: Effect.gen(function* () { | |
| 162 | if (keychainAccessGroup === undefined) return; | |
| 163 | yield* Effect.try({ | |
| 164 | try: () => app.configureWebAuthn({ touchID: { keychainAccessGroup } }), | |
| 165 | catch: (cause) => new PreviewPasskeysConfigureError({ keychainAccessGroup, cause }), | |
| 166 | }).pipe(Effect.catch((error) => Effect.logWarning(error.message, { cause: error.cause }))); | |
| 167 | }).pipe(Effect.withSpan("desktop.previewPasskeys.configure")), | |
| 168 | installSessionHandlers: (session) => { | |
| 169 | if (sessionsWithHandlers.has(session)) return; | |
| 170 | sessionsWithHandlers.add(session); | |
| 171 | session.on("select-webauthn-account", (_event, details, callback) => { | |
| 172 | // The request stays pending until the callback runs, so it must run once. | |
| 173 | void chooseAccount(details).then( | |
| 174 | (credentialId) => callback(credentialId), | |
| 175 | () => callback(), | |
| 176 | ); | |
| 177 | }); | |
| 178 | }, | |
| 179 | attachGuest: (guest) => { | |
| 180 | if (backend === undefined) return () => {}; | |
| 181 | let ceremonyPending = false; | |
| 182 | const serve = async ( | |
| 183 | event: IpcMainInvokeEvent, | |
| 184 | publicKey: { readonly timeout?: unknown } | undefined, | |
| 185 | ceremony: (context: PasskeyCeremonyContext) => Promise<PasskeyCeremonyResult>, | |
| 186 | ): Promise<PasskeyCeremonyResult> => { | |
| 187 | // Only the guest's main frame runs the bridge preload. | |
| 188 | const frame = event.senderFrame; | |
| 189 | if (!frame || frame !== guest.mainFrame) return notAllowed; | |
| 190 | // The page shares a JS world with the preload, so nothing it sends can | |
| 191 | // be trusted for the origin. Read the committed origin before any await: | |
| 192 | // the frame object outlives a navigation. | |
| 193 | const origin = frame.origin; | |
| 194 | // Like Chromium, only a focused page may open the sheet, one at a time. | |
| 195 | if (!isTrustworthyOrigin(origin) || ceremonyPending || !guest.isFocused()) { | |
| 196 | return notAllowed; | |
| 197 | } | |
| 198 | if (typeof publicKey !== "object" || publicKey === null) { | |
| 199 | return { success: false, error: "TypeError" }; | |
| 200 | } | |
| 201 | const host = guest.hostWebContents | |
| 202 | ? BrowserWindow.fromWebContents(guest.hostWebContents) | |
| 203 | : null; | |
| 204 | if (!host || host.isDestroyed()) return notAllowed; | |
| 205 | ||
| 206 | ceremonyPending = true; | |
| 207 | try { | |
| 208 | const result = await withDeadline( | |
| 209 | ceremony({ origin, nativeWindowHandle: host.getNativeWindowHandle() }), | |
| 210 | ceremonyDeadline(publicKey.timeout), | |
| 211 | ); | |
| 212 | // A credential minted for a document that navigated away belongs to nobody. | |
| 213 | const sameDocument = | |
| 214 | !frame.isDestroyed() && frame === guest.mainFrame && frame.origin === origin; | |
| 215 | return sameDocument ? result : notAllowed; | |
| 216 | } catch { | |
| 217 | return notAllowed; | |
| 218 | } finally { | |
| 219 | ceremonyPending = false; | |
| 220 | } | |
| 221 | }; | |
| 222 | const detach = () => { | |
| 223 | guest.ipc.removeHandler(PASSKEY_CREATE_CHANNEL); | |
| 224 | guest.ipc.removeHandler(PASSKEY_GET_CHANNEL); | |
| 225 | }; | |
| 226 | detach(); | |
| 227 | // Option shapes are the page's to get wrong: the native layer validates | |
| 228 | // them and answers with a TypeError. | |
| 229 | guest.ipc.handle( | |
| 230 | PASSKEY_CREATE_CHANNEL, | |
| 231 | (event, publicKey: PublicKeyCredentialCreationOptions) => | |
| 232 | serve(event, publicKey, (context) => backend.create(publicKey, context)), | |
| 233 | ); | |
| 234 | guest.ipc.handle(PASSKEY_GET_CHANNEL, (event, publicKey: PublicKeyCredentialRequestOptions) => | |
| 235 | serve(event, publicKey, (context) => backend.get(publicKey, context)), | |
| 236 | ); | |
| 237 | return detach; | |
| 238 | }, | |
| 239 | }); | |
| 240 | }).pipe(Effect.withSpan("PreviewPasskeys.make")); | |
| 241 | ||
| 242 | export const layer = Layer.effect(PreviewPasskeys, make); |
apps/desktop/src/window/DesktopWindow.test.ts+11| ... | ... | @@ -56,6 +56,7 @@ import { |
| 56 | 56 | import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; |
| 57 | 57 | import * as DesktopWindow from "./DesktopWindow.ts"; |
| 58 | 58 | import * as PreviewManager from "../preview/Manager.ts"; |
| 59 | import * as PreviewPasskeys from "../preview/Passkeys.ts"; | |
| 59 | 60 | |
| 60 | 61 | const environmentInput = { |
| 61 | 62 | dirname: "/repo/apps/desktop/dist-electron", |
| ... | ... | @@ -325,6 +326,11 @@ function layerTest(input: { |
| 325 | 326 | } satisfies ElectronShell.ElectronShell["Service"]), |
| 326 | 327 | layerElectronTheme, |
| 327 | 328 | layerElectronWindow, |
| 329 | Layer.mock(PreviewPasskeys.PreviewPasskeys)({ | |
| 330 | bridgeEnabled: false, | |
| 331 | installSessionHandlers: () => {}, | |
| 332 | attachGuest: () => () => {}, | |
| 333 | }), | |
| 328 | 334 | Layer.mock(PreviewManager.PreviewManager)({ |
| 329 | 335 | getBrowserSession: () => Effect.succeed({} as Electron.Session), |
| 330 | 336 | setMainWindow: () => Effect.void, |
| ... | ... | @@ -433,6 +439,11 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n |
| 433 | 439 | } satisfies ElectronShell.ElectronShell["Service"]), |
| 434 | 440 | layerElectronTheme, |
| 435 | 441 | Layer.succeed(ElectronWindow.ElectronWindow, electronWindowShape), |
| 442 | Layer.mock(PreviewPasskeys.PreviewPasskeys)({ | |
| 443 | bridgeEnabled: false, | |
| 444 | installSessionHandlers: () => {}, | |
| 445 | attachGuest: () => () => {}, | |
| 446 | }), | |
| 436 | 447 | Layer.mock(PreviewManager.PreviewManager)({ |
| 437 | 448 | getBrowserSession: () => Effect.succeed({} as Electron.Session), |
| 438 | 449 | setMainWindow: () => Effect.void, |
apps/desktop/src/window/DesktopWindow.ts+9| ... | ... | @@ -25,7 +25,9 @@ import { |
| 25 | 25 | TRACKPAD_SCROLL_END_CHANNEL, |
| 26 | 26 | WINDOW_FULLSCREEN_STATE_CHANNEL, |
| 27 | 27 | } from "../ipc/channels.ts"; |
| 28 | import { PASSKEY_BRIDGE_ARGUMENT } from "../preview/GuestProtocol.ts"; | |
| 28 | 29 | import * as PreviewManager from "../preview/Manager.ts"; |
| 30 | import * as PreviewPasskeys from "../preview/Passkeys.ts"; | |
| 29 | 31 | import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; |
| 30 | 32 | import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts"; |
| 31 | 33 | import * as ElectronApp from "../electron/ElectronApp.ts"; |
| ... | ... | @@ -326,6 +328,7 @@ export const make = Effect.gen(function* () { |
| 326 | 328 | const electronTheme = yield* ElectronTheme.ElectronTheme; |
| 327 | 329 | const electronWindow = yield* ElectronWindow.ElectronWindow; |
| 328 | 330 | const previewManager = yield* PreviewManager.PreviewManager; |
| 331 | const previewPasskeys = yield* PreviewPasskeys.PreviewPasskeys; | |
| 329 | 332 | const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; |
| 330 | 333 | const clientSettings = yield* DesktopClientSettings.DesktopClientSettings; |
| 331 | 334 | const electronApp = yield* ElectronApp.ElectronApp; |
| ... | ... | @@ -537,6 +540,12 @@ export const make = Effect.gen(function* () { |
| 537 | 540 | webPreferences.nodeIntegrationInSubFrames = false; |
| 538 | 541 | webPreferences.contextIsolation = false; |
| 539 | 542 | webPreferences.disableHtmlFullscreenWindowResize = true; |
| 543 | if (previewPasskeys.bridgeEnabled) { | |
| 544 | webPreferences.additionalArguments = [ | |
| 545 | ...(webPreferences.additionalArguments ?? []), | |
| 546 | PASSKEY_BRIDGE_ARGUMENT, | |
| 547 | ]; | |
| 548 | } | |
| 540 | 549 | }); |
| 541 | 550 | |
| 542 | 551 | const contextMenuContents = new WeakSet<Electron.WebContents>(); |
docs/operations/release.md+10| ... | ... | @@ -553,6 +553,16 @@ Notes: |
| 553 | 553 | - The workflow writes it to a temporary `AuthKey_<id>.p8` file at runtime. |
| 554 | 554 | - The workflow decodes `MACOS_PROVISIONING_PROFILE`, validates it with `security cms`, and passes it |
| 555 | 555 | to the desktop packager. |
| 556 | - In-app browser passkeys depend on the same profile. The packager adds each entitlement only when | |
| 557 | the profile grants it, because macOS will not launch an app that claims more than its profile | |
| 558 | allows. The build log reports which ones it enabled. | |
| 559 | - `keychain-access-groups` (`<TEAM_ID>.com.t3tools.t3code.webauthn`) enables Touch ID passkeys. | |
| 560 | Profiles that grant the team's keychain groups (`<TEAM_ID>.*`) cover it. | |
| 561 | - `com.apple.developer.web-browser.public-key-credential` lets the system passkey sheet (iCloud | |
| 562 | Keychain, password managers, phones, security keys) serve any site. Apple grants it as a | |
| 563 | managed capability: the Account Holder requests it through the | |
| 564 | [macOS Browsers Passkeys form](https://developer.apple.com/contact/request/macos-browsers-passkeys/). | |
| 565 | After approval, regenerate the profile and update `MACOS_PROVISIONING_PROFILE`. | |
| 556 | 566 | |
| 557 | 567 | ## 3) Azure Trusted Signing setup (Windows) |
| 558 | 568 |
pnpm-lock.yaml+136| ... | ... | @@ -27,6 +27,12 @@ catalogs: |
| 27 | 27 | '@pierre/diffs': |
| 28 | 28 | specifier: 1.5.2 |
| 29 | 29 | version: 1.5.2 |
| 30 | '@types/plist': | |
| 31 | specifier: 3.0.5 | |
| 32 | version: 3.0.5 | |
| 33 | electron-webauthn: | |
| 34 | specifier: 1.3.1 | |
| 35 | version: 1.3.1 | |
| 30 | 36 | jose: |
| 31 | 37 | specifier: 6.2.2 |
| 32 | 38 | version: 6.2.2 |
| ... | ... | @@ -42,6 +48,9 @@ catalogs: |
| 42 | 48 | micromark-util-types: |
| 43 | 49 | specifier: 2.0.2 |
| 44 | 50 | version: 2.0.2 |
| 51 | plist: | |
| 52 | specifier: 3.1.1 | |
| 53 | version: 3.1.1 | |
| 45 | 54 | rehype-raw: |
| 46 | 55 | specifier: 7.0.0 |
| 47 | 56 | version: 7.0.0 |
| ... | ... | @@ -60,6 +69,9 @@ catalogs: |
| 60 | 69 | remark-rehype: |
| 61 | 70 | specifier: 11.1.2 |
| 62 | 71 | version: 11.1.2 |
| 72 | tldts: | |
| 73 | specifier: 7.4.2 | |
| 74 | version: 7.4.2 | |
| 63 | 75 | typescript: |
| 64 | 76 | specifier: 7.0.2 |
| 65 | 77 | version: 7.0.2 |
| ... | ... | @@ -117,6 +129,8 @@ overrides: |
| 117 | 129 | '@opencode/protocol>effect': 4.0.2 |
| 118 | 130 | '@opencode/schema>effect': 4.0.2 |
| 119 | 131 | node-abi: 4.33.0 |
| 132 | electron-webauthn>@electron-webauthn/macos: 1.3.1 | |
| 133 | '@electron-webauthn/macos>objc-js': 1.5.0 | |
| 120 | 134 | lightningcss: 1.33.0 |
| 121 | 135 | tailwindcss: 4.3.3 |
| 122 | 136 | '@types/ws@^8': 8.18.2 |
| ... | ... | @@ -229,6 +243,9 @@ importers: |
| 229 | 243 | electron-updater: |
| 230 | 244 | specifier: ^6.8.9 |
| 231 | 245 | version: 6.8.9 |
| 246 | electron-webauthn: | |
| 247 | specifier: 'catalog:' | |
| 248 | version: 1.3.1(typescript@7.0.2) | |
| 232 | 249 | ffi-rs: |
| 233 | 250 | specifier: 1.3.2 |
| 234 | 251 | version: 1.3.2 |
| ... | ... | @@ -238,6 +255,9 @@ importers: |
| 238 | 255 | react-grab: |
| 239 | 256 | specifier: ^0.1.32 |
| 240 | 257 | version: 0.1.44(react@19.2.6) |
| 258 | tldts: | |
| 259 | specifier: 'catalog:' | |
| 260 | version: 7.4.2 | |
| 241 | 261 | devDependencies: |
| 242 | 262 | '@effect/vitest': |
| 243 | 263 | specifier: 4.0.2 |
| ... | ... | @@ -1554,6 +1574,9 @@ importers: |
| 1554 | 1574 | effect: |
| 1555 | 1575 | specifier: 4.0.2 |
| 1556 | 1576 | version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) |
| 1577 | plist: | |
| 1578 | specifier: 'catalog:' | |
| 1579 | version: 3.1.1 | |
| 1557 | 1580 | pngjs: |
| 1558 | 1581 | specifier: 7.0.0 |
| 1559 | 1582 | version: 7.0.0 |
| ... | ... | @@ -1564,6 +1587,9 @@ importers: |
| 1564 | 1587 | '@effect/vitest': |
| 1565 | 1588 | specifier: 4.0.2 |
| 1566 | 1589 | version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |
| 1590 | '@types/plist': | |
| 1591 | specifier: 'catalog:' | |
| 1592 | version: 3.0.5 | |
| 1567 | 1593 | '@types/pngjs': |
| 1568 | 1594 | specifier: 6.0.5 |
| 1569 | 1595 | version: 6.0.5 |
| ... | ... | @@ -2868,6 +2894,15 @@ packages: |
| 2868 | 2894 | resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==} |
| 2869 | 2895 | engines: {node: '>=22.12.0'} |
| 2870 | 2896 | |
| 2897 | '@electron-webauthn/macos@1.3.1': | |
| 2898 | resolution: {integrity: sha512-NJA4I83ulh3pOfkv7sBkEGecTxaCtjFUbGTAi+BLNWdFc463AY7DaUprsqbGr+WAnD8SIUpuHSO7vC0C+HPu6g==} | |
| 2899 | os: [darwin] | |
| 2900 | peerDependencies: | |
| 2901 | typescript: ^6.0.2 | |
| 2902 | ||
| 2903 | '@electron-webauthn/types@1.3.1': | |
| 2904 | resolution: {integrity: sha512-C7Jy6dg32/QzIAl1E110acjpp6+b67xqZJL4L0rL2X2VIOJx4m+vfcWl3AjC4JgkxUBcSMSNa85bay2/473y/Q==} | |
| 2905 | ||
| 2871 | 2906 | '@electron/asar@3.4.1': |
| 2872 | 2907 | resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} |
| 2873 | 2908 | engines: {node: '>=10.12.0'} |
| ... | ... | @@ -4215,9 +4250,32 @@ packages: |
| 4215 | 4250 | '@opencode/schema@2.0.23': |
| 4216 | 4251 | resolution: {integrity: sha512-DglA+CCNmCaiAJ8FqAKo2EKIkCOF9/EmMEYowcXhTrQcoJ7eaTx7ytGjPgis6VHQuU4aaAgwZYw96ypS3ojsIw==} |
| 4217 | 4252 | |
| 4253 | '@oslojs/asn1@1.0.0': | |
| 4254 | resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} | |
| 4255 | deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. | |
| 4256 | ||
| 4257 | '@oslojs/binary@1.0.0': | |
| 4258 | resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} | |
| 4259 | deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. | |
| 4260 | ||
| 4261 | '@oslojs/cbor@1.0.0': | |
| 4262 | resolution: {integrity: sha512-AY6Lknexs7n2xp8Cgey95c+975VG7XOk4UEdRdNFxHmDDbuf47OC/LAVRsl14DeTLwo8W6xr3HLFwUFmKcndTQ==} | |
| 4263 | deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. | |
| 4264 | ||
| 4265 | '@oslojs/crypto@1.0.0': | |
| 4266 | resolution: {integrity: sha512-dVz8TkkgYdr3tlwxHd7SCYGxoN7ynwHLA0nei/Aq9C+ERU0BK+U8+/3soEzBUxUNKYBf42351DyJUZ2REla50w==} | |
| 4267 | deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. | |
| 4268 | ||
| 4269 | '@oslojs/encoding@1.0.0': | |
| 4270 | resolution: {integrity: sha512-dyIB0SdZgMm5BhGwdSp8rMxEFIopLKxDG1vxIBaiogyom6ZqH2aXPb6DEC2WzOOWKdPSq1cxdNeRx2wAn1Z+ZQ==} | |
| 4271 | ||
| 4218 | 4272 | '@oslojs/encoding@1.1.0': |
| 4219 | 4273 | resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} |
| 4220 | 4274 | |
| 4275 | '@oslojs/webauthn@1.0.0': | |
| 4276 | resolution: {integrity: sha512-2ZRpbt3msNURwvjmavzq9vrNlxUnWFBGMYqbC1kO3fYBLskL7r4DiLJT1wbtLoI+hclFwjhl48YhRFBl6RWg1A==} | |
| 4277 | deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. | |
| 4278 | ||
| 4221 | 4279 | '@oxc-parser/binding-android-arm-eabi@0.147.0': |
| 4222 | 4280 | resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==} |
| 4223 | 4281 | engines: {node: ^20.19.0 || >=22.12.0} |
| ... | ... | @@ -6049,6 +6107,9 @@ packages: |
| 6049 | 6107 | '@types/node@24.12.4': |
| 6050 | 6108 | resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} |
| 6051 | 6109 | |
| 6110 | '@types/plist@3.0.5': | |
| 6111 | resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} | |
| 6112 | ||
| 6052 | 6113 | '@types/pngjs@6.0.5': |
| 6053 | 6114 | resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} |
| 6054 | 6115 | |
| ... | ... | @@ -7991,6 +8052,11 @@ packages: |
| 7991 | 8052 | electron-updater@6.8.9: |
| 7992 | 8053 | resolution: {integrity: sha512-ZhVxM9iGONUpZGI1FxdMRgJjUFXi7AYGVa5PwKlO1tV1/4zDxQmfKpXOHVztKrd6L9rLcFjERvi1Mf2vxyTkig==} |
| 7993 | 8054 | |
| 8055 | electron-webauthn@1.3.1: | |
| 8056 | resolution: {integrity: sha512-I8/SOjPfnIiVjN38eTX3BRZfuFDGzLWtlByh4OecmlVkfxQt9R/zCwEm4PquyvaqP+xtGgkFrBxwOuJ+DhWlDg==} | |
| 8057 | peerDependencies: | |
| 8058 | typescript: ^6.0.2 | |
| 8059 | ||
| 7994 | 8060 | electron-winstaller@5.4.0: |
| 7995 | 8061 | resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} |
| 7996 | 8062 | engines: {node: '>=8.0.0'} |
| ... | ... | @@ -9813,6 +9879,10 @@ packages: |
| 9813 | 9879 | node-addon-api@7.1.1: |
| 9814 | 9880 | resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} |
| 9815 | 9881 | |
| 9882 | node-addon-api@8.9.2: | |
| 9883 | resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} | |
| 9884 | engines: {node: ^18 || ^20 || >= 21} | |
| 9885 | ||
| 9816 | 9886 | node-api-version@0.2.1: |
| 9817 | 9887 | resolution: {integrity: sha512-2xP/IGGMmmSQpI1+O/k72jF/ykvZ89JeuKX3TLJAYPDVLUalrshrLHkeVcCCZqG/eEa635cr8IBYzgnDvM2O8Q==} |
| 9818 | 9888 | |
| ... | ... | @@ -9908,6 +9978,10 @@ packages: |
| 9908 | 9978 | resolution: {integrity: sha512-i8iA8uij0g1YQzS8uOJPSRCgwDjO9warIHUAu1Fqj877Wc3wlfxDYBioYWgKTBF2+URVJttyDWSEpmd99nlvtQ==} |
| 9909 | 9979 | engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} |
| 9910 | 9980 | |
| 9981 | objc-js@1.5.0: | |
| 9982 | resolution: {integrity: sha512-IHmouX5xYrE2UyGRRlpwY7u2HJVjF6P/P+jy6Vqga+Ya7RAf/hFeNkt4KgTkGUMM3VmnBVUEMJ0tKWY2RlYgKg==} | |
| 9983 | os: [darwin] | |
| 9984 | ||
| 9911 | 9985 | object-assign@4.1.1: |
| 9912 | 9986 | resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} |
| 9913 | 9987 | engines: {node: '>=0.10.0'} |
| ... | ... | @@ -13599,6 +13673,16 @@ snapshots: |
| 13599 | 13673 | |
| 13600 | 13674 | '@electron-internal/extract-zip@1.0.5': {} |
| 13601 | 13675 | |
| 13676 | '@electron-webauthn/macos@1.3.1(typescript@7.0.2)': | |
| 13677 | dependencies: | |
| 13678 | '@electron-webauthn/types': 1.3.1 | |
| 13679 | '@oslojs/webauthn': 1.0.0 | |
| 13680 | objc-js: 1.5.0 | |
| 13681 | typescript: 7.0.2 | |
| 13682 | optional: true | |
| 13683 | ||
| 13684 | '@electron-webauthn/types@1.3.1': {} | |
| 13685 | ||
| 13602 | 13686 | '@electron/asar@3.4.1': |
| 13603 | 13687 | dependencies: |
| 13604 | 13688 | commander: 5.1.0 |
| ... | ... | @@ -14991,8 +15075,39 @@ snapshots: |
| 14991 | 15075 | '@standard-schema/spec': 1.1.0 |
| 14992 | 15076 | effect: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) |
| 14993 | 15077 | |
| 15078 | '@oslojs/asn1@1.0.0': | |
| 15079 | dependencies: | |
| 15080 | '@oslojs/binary': 1.0.0 | |
| 15081 | optional: true | |
| 15082 | ||
| 15083 | '@oslojs/binary@1.0.0': | |
| 15084 | optional: true | |
| 15085 | ||
| 15086 | '@oslojs/cbor@1.0.0': | |
| 15087 | dependencies: | |
| 15088 | '@oslojs/binary': 1.0.0 | |
| 15089 | optional: true | |
| 15090 | ||
| 15091 | '@oslojs/crypto@1.0.0': | |
| 15092 | dependencies: | |
| 15093 | '@oslojs/asn1': 1.0.0 | |
| 15094 | '@oslojs/binary': 1.0.0 | |
| 15095 | optional: true | |
| 15096 | ||
| 15097 | '@oslojs/encoding@1.0.0': | |
| 15098 | optional: true | |
| 15099 | ||
| 14994 | 15100 | '@oslojs/encoding@1.1.0': {} |
| 14995 | 15101 | |
| 15102 | '@oslojs/webauthn@1.0.0': | |
| 15103 | dependencies: | |
| 15104 | '@oslojs/asn1': 1.0.0 | |
| 15105 | '@oslojs/binary': 1.0.0 | |
| 15106 | '@oslojs/cbor': 1.0.0 | |
| 15107 | '@oslojs/crypto': 1.0.0 | |
| 15108 | '@oslojs/encoding': 1.0.0 | |
| 15109 | optional: true | |
| 15110 | ||
| 14996 | 15111 | '@oxc-parser/binding-android-arm-eabi@0.147.0': |
| 14997 | 15112 | optional: true |
| 14998 | 15113 | |
| ... | ... | @@ -16589,6 +16704,11 @@ snapshots: |
| 16589 | 16704 | dependencies: |
| 16590 | 16705 | undici-types: 7.16.0 |
| 16591 | 16706 | |
| 16707 | '@types/plist@3.0.5': | |
| 16708 | dependencies: | |
| 16709 | '@types/node': 24.12.4 | |
| 16710 | xmlbuilder: 15.1.1 | |
| 16711 | ||
| 16592 | 16712 | '@types/pngjs@6.0.5': |
| 16593 | 16713 | dependencies: |
| 16594 | 16714 | '@types/node': 24.12.4 |
| ... | ... | @@ -18436,6 +18556,13 @@ snapshots: |
| 18436 | 18556 | transitivePeerDependencies: |
| 18437 | 18557 | - supports-color |
| 18438 | 18558 | |
| 18559 | electron-webauthn@1.3.1(typescript@7.0.2): | |
| 18560 | dependencies: | |
| 18561 | '@electron-webauthn/types': 1.3.1 | |
| 18562 | typescript: 7.0.2 | |
| 18563 | optionalDependencies: | |
| 18564 | '@electron-webauthn/macos': 1.3.1(typescript@7.0.2) | |
| 18565 | ||
| 18439 | 18566 | electron-winstaller@5.4.0: |
| 18440 | 18567 | dependencies: |
| 18441 | 18568 | '@electron/asar': 3.4.1 |
| ... | ... | @@ -20739,6 +20866,9 @@ snapshots: |
| 20739 | 20866 | |
| 20740 | 20867 | node-addon-api@7.1.1: {} |
| 20741 | 20868 | |
| 20869 | node-addon-api@8.9.2: | |
| 20870 | optional: true | |
| 20871 | ||
| 20742 | 20872 | node-api-version@0.2.1: |
| 20743 | 20873 | dependencies: |
| 20744 | 20874 | semver: 7.8.5 |
| ... | ... | @@ -20857,6 +20987,12 @@ snapshots: |
| 20857 | 20987 | dependencies: |
| 20858 | 20988 | flow-enums-runtime: 0.0.6 |
| 20859 | 20989 | |
| 20990 | objc-js@1.5.0: | |
| 20991 | dependencies: | |
| 20992 | node-addon-api: 8.9.2 | |
| 20993 | node-gyp-build: 4.8.4 | |
| 20994 | optional: true | |
| 20995 | ||
| 20860 | 20996 | object-assign@4.1.1: {} |
| 20861 | 20997 | |
| 20862 | 20998 | object-inspect@1.13.4: {} |
pnpm-workspace.yaml+12| ... | ... | @@ -18,6 +18,8 @@ allowBuilds: |
| 18 | 18 | msgpackr-extract: true |
| 19 | 19 | msw: false |
| 20 | 20 | node-pty: true |
| 21 | # Ships N-API prebuilds; its install script only rebuilds from source. | |
| 22 | objc-js: false | |
| 21 | 23 | sharp: true |
| 22 | 24 | utf-8-validate: false |
| 23 | 25 | workerd: false |
| ... | ... | @@ -38,6 +40,7 @@ catalog: |
| 38 | 40 | "@effect/sql-sqlite-do": 4.0.2 |
| 39 | 41 | "@effect/tsgo": 0.46.1 |
| 40 | 42 | "@effect/vitest": 4.0.2 |
| 43 | "@electron-webauthn/macos": 1.3.1 | |
| 41 | 44 | "@legendapp/list": 3.3.5 |
| 42 | 45 | "@noble/curves": 1.9.1 |
| 43 | 46 | "@noble/hashes": 1.8.0 |
| ... | ... | @@ -46,11 +49,16 @@ catalog: |
| 46 | 49 | "@tailwindcss/oxide": 4.3.3 |
| 47 | 50 | "@tailwindcss/vite": 4.3.3 |
| 48 | 51 | "@types/node": 24.12.4 |
| 52 | "@types/plist": 3.0.5 | |
| 49 | 53 | "@types/ws": 8.18.2 |
| 50 | 54 | effect: 4.0.2 |
| 55 | electron-webauthn: 1.3.1 | |
| 51 | 56 | jose: 6.2.2 |
| 52 | 57 | lightningcss: 1.33.0 |
| 58 | objc-js: 1.5.0 | |
| 59 | plist: 3.1.1 | |
| 53 | 60 | tailwindcss: 4.3.3 |
| 61 | tldts: 7.4.2 | |
| 54 | 62 | typescript: 7.0.2 |
| 55 | 63 | undici: 8.11.2 |
| 56 | 64 | vite: npm:@voidzero-dev/vite-plus-core@1.0.0 |
| ... | ... | @@ -249,6 +257,10 @@ overrides: |
| 249 | 257 | "@opencode/protocol>effect": "catalog:" |
| 250 | 258 | "@opencode/schema>effect": "catalog:" |
| 251 | 259 | node-abi: 4.33.0 |
| 260 | # The desktop app installs these without a lockfile when it packages them, so | |
| 261 | # pin the native passkey code that runs in its main process. | |
| 262 | "electron-webauthn>@electron-webauthn/macos": "catalog:" | |
| 263 | "@electron-webauthn/macos>objc-js": "catalog:" | |
| 252 | 264 | lightningcss: "catalog:" |
| 253 | 265 | tailwindcss: "catalog:" |
| 254 | 266 | # Ranges from @effect/platform-node, jsdom, unifont, Expo and libsql otherwise |
scripts/build-desktop-artifact.test.ts+90-1| ... | ... | @@ -44,6 +44,7 @@ import { |
| 44 | 44 | preflightMacDesktopBuild, |
| 45 | 45 | preflightWindowsDesktopBuild, |
| 46 | 46 | renderMacPasskeyEntitlements, |
| 47 | resolveMacWebAuthnEntitlements, | |
| 47 | 48 | resolveClerkPasskeyNativeArtifacts, |
| 48 | 49 | resolveMacPasskeySigningConfiguration, |
| 49 | 50 | resolveDesktopRuntimeDependencies, |
| ... | ... | @@ -95,6 +96,12 @@ import { BRAND_ASSET_PATHS } from "./lib/brand-assets.ts"; |
| 95 | 96 | import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 96 | 97 | import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; |
| 97 | 98 | |
| 99 | // Keeps pnpm from auto-installing TypeScript, a types-only peer, into the app. | |
| 100 | const stagePackageExtensions = { | |
| 101 | "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } }, | |
| 102 | "@electron-webauthn/macos": { peerDependenciesMeta: { typescript: { optional: true } } }, | |
| 103 | }; | |
| 104 | ||
| 98 | 105 | // A minimal stand-in for the Linux CLI release archive: one top-level |
| 99 | 106 | // directory named after the archive stem holding the executable, the web |
| 100 | 107 | // client, and the runtime externals with node-pty built from source. |
| ... | ... | @@ -454,6 +461,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 454 | 461 | os: ["darwin"], |
| 455 | 462 | cpu: ["x64"], |
| 456 | 463 | }, |
| 464 | packageExtensions: stagePackageExtensions, | |
| 457 | 465 | }); |
| 458 | 466 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "linux", arch: "x64" }), { |
| 459 | 467 | supportedArchitectures: { |
| ... | ... | @@ -461,6 +469,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 461 | 469 | cpu: ["x64"], |
| 462 | 470 | libc: ["glibc"], |
| 463 | 471 | }, |
| 472 | packageExtensions: stagePackageExtensions, | |
| 464 | 473 | }); |
| 465 | 474 | // Windows stages only win32 natives; WSL runs the separately built Linux |
| 466 | 475 | // CLI archive rather than anything installed here. |
| ... | ... | @@ -469,18 +478,21 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 469 | 478 | os: ["win32"], |
| 470 | 479 | cpu: ["x64"], |
| 471 | 480 | }, |
| 481 | packageExtensions: stagePackageExtensions, | |
| 472 | 482 | }); |
| 473 | 483 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "win", arch: "arm64" }), { |
| 474 | 484 | supportedArchitectures: { |
| 475 | 485 | os: ["win32"], |
| 476 | 486 | cpu: ["arm64"], |
| 477 | 487 | }, |
| 488 | packageExtensions: stagePackageExtensions, | |
| 478 | 489 | }); |
| 479 | 490 | assert.deepStrictEqual(createStageWorkspaceConfig({ platform: "mac", arch: "universal" }), { |
| 480 | 491 | supportedArchitectures: { |
| 481 | 492 | os: ["darwin"], |
| 482 | 493 | cpu: ["arm64", "x64"], |
| 483 | 494 | }, |
| 495 | packageExtensions: stagePackageExtensions, | |
| 484 | 496 | }); |
| 485 | 497 | }); |
| 486 | 498 | |
| ... | ... | @@ -507,6 +519,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 507 | 519 | cpu: ["x64"], |
| 508 | 520 | libc: ["glibc"], |
| 509 | 521 | }, |
| 522 | packageExtensions: stagePackageExtensions, | |
| 510 | 523 | allowBuilds: { |
| 511 | 524 | electron: true, |
| 512 | 525 | "node-pty": true, |
| ... | ... | @@ -537,6 +550,7 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 537 | 550 | os: ["darwin"], |
| 538 | 551 | cpu: ["arm64"], |
| 539 | 552 | }, |
| 553 | packageExtensions: stagePackageExtensions, | |
| 540 | 554 | }, |
| 541 | 555 | ); |
| 542 | 556 | }); |
| ... | ... | @@ -1886,7 +1900,10 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1886 | 1900 | T3CODE_CLERK_PASSKEY_RP_DOMAINS: |
| 1887 | 1901 | " Clerk.Example.com,example.clerk.accounts.dev,clerk.example.com ", |
| 1888 | 1902 | }); |
| 1889 | const entitlements = renderMacPasskeyEntitlements(configuration); | |
| 1903 | const entitlements = renderMacPasskeyEntitlements(configuration, { | |
| 1904 | touchIdKeychainAccessGroup: undefined, | |
| 1905 | browserPasskeys: false, | |
| 1906 | }); | |
| 1890 | 1907 | |
| 1891 | 1908 | assert.deepStrictEqual(configuration.rpDomains, [ |
| 1892 | 1909 | "clerk.example.com", |
| ... | ... | @@ -1896,6 +1913,78 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { |
| 1896 | 1913 | assert.include(entitlements, "<string>webcredentials:clerk.example.com</string>"); |
| 1897 | 1914 | assert.include(entitlements, "<string>webcredentials:example.clerk.accounts.dev</string>"); |
| 1898 | 1915 | assert.include(entitlements, "<key>com.apple.security.cs.allow-jit</key>"); |
| 1916 | assert.notInclude(entitlements, "keychain-access-groups"); | |
| 1917 | assert.notInclude(entitlements, "com.apple.developer.web-browser.public-key-credential"); | |
| 1918 | }); | |
| 1919 | ||
| 1920 | it("grants in-app browser passkey entitlements only when the provisioning profile does", () => { | |
| 1921 | const configuration = { appId: "com.t3tools.t3code", teamId: "ABC1234567" }; | |
| 1922 | // Profiles are CMS envelopes around a plain XML plist. | |
| 1923 | const profile = (entitlements: string, outside = "") => | |
| 1924 | `0\x82\x1f\x9a\x06\t*\x86H<?xml version="1.0"?><plist version="1.0"><dict>${outside}<key>Entitlements</key><dict>${entitlements}</dict></dict></plist>\x00\x01`; | |
| 1925 | const teamWildcardGroups = `<key>keychain-access-groups</key> | |
| 1926 | <array> | |
| 1927 | <string>ABC1234567.*</string> | |
| 1928 | <string>com.apple.token</string> | |
| 1929 | </array>`; | |
| 1930 | ||
| 1931 | assert.deepStrictEqual(resolveMacWebAuthnEntitlements(profile(""), configuration), { | |
| 1932 | touchIdKeychainAccessGroup: undefined, | |
| 1933 | browserPasskeys: false, | |
| 1934 | }); | |
| 1935 | assert.deepStrictEqual( | |
| 1936 | resolveMacWebAuthnEntitlements( | |
| 1937 | profile("<key>keychain-access-groups</key><array><string>OTHERTEAM1.*</string></array>"), | |
| 1938 | configuration, | |
| 1939 | ), | |
| 1940 | { touchIdKeychainAccessGroup: undefined, browserPasskeys: false }, | |
| 1941 | ); | |
| 1942 | // Only real values inside the Entitlements dict count: not comments, not | |
| 1943 | // explicit false, not keys elsewhere in the profile. | |
| 1944 | assert.deepStrictEqual( | |
| 1945 | resolveMacWebAuthnEntitlements( | |
| 1946 | profile( | |
| 1947 | `<!-- ${teamWildcardGroups} | |
| 1948 | <key>com.apple.developer.web-browser.public-key-credential</key><true/> --> | |
| 1949 | <key>com.apple.developer.web-browser.public-key-credential</key><false/>`, | |
| 1950 | teamWildcardGroups, | |
| 1951 | ), | |
| 1952 | configuration, | |
| 1953 | ), | |
| 1954 | { touchIdKeychainAccessGroup: undefined, browserPasskeys: false }, | |
| 1955 | ); | |
| 1956 | assert.deepStrictEqual( | |
| 1957 | resolveMacWebAuthnEntitlements( | |
| 1958 | profile( | |
| 1959 | `${teamWildcardGroups} | |
| 1960 | <key>com.apple.developer.web-browser.public-key-credential</key> | |
| 1961 | <true/>`, | |
| 1962 | ), | |
| 1963 | configuration, | |
| 1964 | ), | |
| 1965 | { | |
| 1966 | touchIdKeychainAccessGroup: "ABC1234567.com.t3tools.t3code.webauthn", | |
| 1967 | browserPasskeys: true, | |
| 1968 | }, | |
| 1969 | ); | |
| 1970 | ||
| 1971 | const entitlements = renderMacPasskeyEntitlements( | |
| 1972 | { ...configuration, rpDomains: ["clerk.example.com"], provisioningProfilePath: "" }, | |
| 1973 | resolveMacWebAuthnEntitlements( | |
| 1974 | profile( | |
| 1975 | `${teamWildcardGroups}<key>com.apple.developer.web-browser.public-key-credential</key><true/>`, | |
| 1976 | ), | |
| 1977 | configuration, | |
| 1978 | ), | |
| 1979 | ); | |
| 1980 | assert.match( | |
| 1981 | entitlements, | |
| 1982 | /<key>keychain-access-groups<\/key>\s*<array>\s*<string>ABC1234567\.com\.t3tools\.t3code\.webauthn<\/string>\s*<\/array>/u, | |
| 1983 | ); | |
| 1984 | assert.match( | |
| 1985 | entitlements, | |
| 1986 | /<key>com\.apple\.developer\.web-browser\.public-key-credential<\/key>\s*<true\/>/u, | |
| 1987 | ); | |
| 1899 | 1988 | }); |
| 1900 | 1989 | |
| 1901 | 1990 | it("rejects incomplete macOS passkey signing configuration", () => { |
scripts/build-desktop-artifact.ts+105-2| ... | ... | @@ -4,6 +4,8 @@ |
| 4 | 4 | import * as NodeFSP from "node:fs/promises"; |
| 5 | 5 | import * as NodeCrypto from "node:crypto"; |
| 6 | 6 | import * as NodeModule from "node:module"; |
| 7 | // plist is CommonJS; Node cannot load its named exports from an ES module. | |
| 8 | import Plist from "plist"; | |
| 7 | 9 | |
| 8 | 10 | import { |
| 9 | 11 | createPackageWithOptions, |
| ... | ... | @@ -80,10 +82,28 @@ const StageWorkspaceConfig = Schema.Struct({ |
| 80 | 82 | allowBuilds: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), |
| 81 | 83 | patchedDependencies: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 82 | 84 | overrides: Schema.optional(Schema.Record(Schema.String, Schema.String)), |
| 85 | packageExtensions: Schema.optional( | |
| 86 | Schema.Record( | |
| 87 | Schema.String, | |
| 88 | Schema.Struct({ | |
| 89 | peerDependenciesMeta: Schema.Record( | |
| 90 | Schema.String, | |
| 91 | Schema.Struct({ optional: Schema.Boolean }), | |
| 92 | ), | |
| 93 | }), | |
| 94 | ), | |
| 95 | ), | |
| 83 | 96 | nodeLinker: Schema.optional(Schema.Literals(["hoisted"])), |
| 84 | 97 | }); |
| 85 | 98 | type StageWorkspaceConfig = typeof StageWorkspaceConfig.Type; |
| 86 | 99 | |
| 100 | // electron-webauthn declares TypeScript as a peer only for its typings. pnpm | |
| 101 | // auto-installs missing peers, which would ship a compiler inside the app. | |
| 102 | const STAGE_PACKAGE_EXTENSIONS = { | |
| 103 | "electron-webauthn": { peerDependenciesMeta: { typescript: { optional: true } } }, | |
| 104 | "@electron-webauthn/macos": { peerDependenciesMeta: { typescript: { optional: true } } }, | |
| 105 | } as const; | |
| 106 | ||
| 87 | 107 | const RepoRoot = Effect.service(Path.Path).pipe( |
| 88 | 108 | Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))), |
| 89 | 109 | ); |
| ... | ... | @@ -926,6 +946,7 @@ interface StagePackageJson { |
| 926 | 946 | readonly version: string; |
| 927 | 947 | readonly buildVersion: string; |
| 928 | 948 | readonly t3codeCommitHash: string; |
| 949 | readonly t3codeWebAuthn?: MacWebAuthnEntitlements; | |
| 929 | 950 | readonly private: true; |
| 930 | 951 | readonly packageManager: string; |
| 931 | 952 | readonly description: string; |
| ... | ... | @@ -1289,12 +1310,80 @@ function escapeXml(value: string): string { |
| 1289 | 1310 | .replaceAll("'", "&apos;"); |
| 1290 | 1311 | } |
| 1291 | 1312 | |
| 1313 | /** | |
| 1314 | * Passkey entitlements for the in-app browser. Each is granted only when the | |
| 1315 | * provisioning profile authorizes it: macOS refuses to launch an app that | |
| 1316 | * claims a restricted entitlement its embedded profile does not carry. | |
| 1317 | */ | |
| 1318 | export interface MacWebAuthnEntitlements { | |
| 1319 | /** Keychain group for Electron's Touch ID passkeys. */ | |
| 1320 | readonly touchIdKeychainAccessGroup: string | undefined; | |
| 1321 | /** Apple's managed browser entitlement, which allows passkeys for any site. */ | |
| 1322 | readonly browserPasskeys: boolean; | |
| 1323 | } | |
| 1324 | ||
| 1325 | const BROWSER_PASSKEYS_ENTITLEMENT = "com.apple.developer.web-browser.public-key-credential"; | |
| 1326 | ||
| 1327 | const ProvisioningProfilePlist = Schema.Struct({ | |
| 1328 | Entitlements: Schema.Struct({ | |
| 1329 | "keychain-access-groups": Schema.optional(Schema.Array(Schema.String)), | |
| 1330 | [BROWSER_PASSKEYS_ENTITLEMENT]: Schema.optional(Schema.Boolean), | |
| 1331 | }), | |
| 1332 | }); | |
| 1333 | const isProvisioningProfilePlist = Schema.is(ProvisioningProfilePlist); | |
| 1334 | ||
| 1335 | /** | |
| 1336 | * Reads the Entitlements dict of the XML plist a provisioning profile wraps in | |
| 1337 | * its CMS envelope. Anything unreadable grants nothing. | |
| 1338 | */ | |
| 1339 | const readProfileEntitlements = (provisioningProfile: string) => { | |
| 1340 | const start = provisioningProfile.indexOf("<?xml"); | |
| 1341 | const end = provisioningProfile.indexOf("</plist>", start); | |
| 1342 | if (start === -1 || end === -1) return undefined; | |
| 1343 | try { | |
| 1344 | const profile: unknown = Plist.parse(provisioningProfile.slice(start, end + "</plist>".length)); | |
| 1345 | return isProvisioningProfilePlist(profile) ? profile.Entitlements : undefined; | |
| 1346 | } catch { | |
| 1347 | return undefined; | |
| 1348 | } | |
| 1349 | }; | |
| 1350 | ||
| 1351 | export function resolveMacWebAuthnEntitlements( | |
| 1352 | provisioningProfile: string, | |
| 1353 | configuration: Pick<MacPasskeySigningConfiguration, "appId" | "teamId">, | |
| 1354 | ): MacWebAuthnEntitlements { | |
| 1355 | const entitlements = readProfileEntitlements(provisioningProfile); | |
| 1356 | const keychainAccessGroup = `${configuration.teamId}.${configuration.appId}.webauthn`; | |
| 1357 | const keychainGroupAuthorized = (entitlements?.["keychain-access-groups"] ?? []).some((group) => | |
| 1358 | group.endsWith("*") | |
| 1359 | ? keychainAccessGroup.startsWith(group.slice(0, -1)) | |
| 1360 | : group === keychainAccessGroup, | |
| 1361 | ); | |
| 1362 | return { | |
| 1363 | touchIdKeychainAccessGroup: keychainGroupAuthorized ? keychainAccessGroup : undefined, | |
| 1364 | browserPasskeys: entitlements?.[BROWSER_PASSKEYS_ENTITLEMENT] === true, | |
| 1365 | }; | |
| 1366 | } | |
| 1367 | ||
| 1292 | 1368 | export function renderMacPasskeyEntitlements( |
| 1293 | 1369 | configuration: MacPasskeySigningConfiguration, |
| 1370 | webAuthn: MacWebAuthnEntitlements, | |
| 1294 | 1371 | ): string { |
| 1295 | 1372 | const associatedDomains = configuration.rpDomains |
| 1296 | 1373 | .map((domain) => ` <string>webcredentials:${escapeXml(domain)}</string>`) |
| 1297 | 1374 | .join("\n"); |
| 1375 | const keychainAccessGroups = webAuthn.touchIdKeychainAccessGroup | |
| 1376 | ? ` | |
| 1377 | <key>keychain-access-groups</key> | |
| 1378 | <array> | |
| 1379 | <string>${escapeXml(webAuthn.touchIdKeychainAccessGroup)}</string> | |
| 1380 | </array>` | |
| 1381 | : ""; | |
| 1382 | const browserPasskeys = webAuthn.browserPasskeys | |
| 1383 | ? ` | |
| 1384 | <key>${BROWSER_PASSKEYS_ENTITLEMENT}</key> | |
| 1385 | <true/>` | |
| 1386 | : ""; | |
| 1298 | 1387 | |
| 1299 | 1388 | return `<?xml version="1.0" encoding="UTF-8"?> |
| 1300 | 1389 | <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> |
| ... | ... | @@ -1307,7 +1396,7 @@ export function renderMacPasskeyEntitlements( |
| 1307 | 1396 | <key>com.apple.developer.associated-domains</key> |
| 1308 | 1397 | <array> |
| 1309 | 1398 | ${associatedDomains} |
| 1310 | </array> | |
| 1399 | </array>${keychainAccessGroups}${browserPasskeys} | |
| 1311 | 1400 | <key>com.apple.security.cs.allow-jit</key> |
| 1312 | 1401 | <true/> |
| 1313 | 1402 | <key>com.apple.security.cs.allow-unsigned-executable-memory</key> |
| ... | ... | @@ -1535,6 +1624,7 @@ export function createStageWorkspaceConfig(input: { |
| 1535 | 1624 | ? { patchedDependencies } |
| 1536 | 1625 | : {}), |
| 1537 | 1626 | ...(overrides && Object.keys(overrides).length > 0 ? { overrides } : {}), |
| 1627 | packageExtensions: STAGE_PACKAGE_EXTENSIONS, | |
| 1538 | 1628 | }; |
| 1539 | 1629 | } |
| 1540 | 1630 | |
| ... | ... | @@ -3673,13 +3763,24 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3673 | 3763 | const macEntitlementsPath = macPasskeySigning |
| 3674 | 3764 | ? path.join(stageAppDir, "entitlements.mac.plist") |
| 3675 | 3765 | : undefined; |
| 3766 | let macWebAuthn: MacWebAuthnEntitlements | undefined; | |
| 3676 | 3767 | if (macPasskeySigning && macEntitlementsPath) { |
| 3677 | 3768 | if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) { |
| 3678 | 3769 | return yield* new MacProvisioningProfileNotFoundError({ |
| 3679 | 3770 | provisioningProfilePath: macPasskeySigning.provisioningProfilePath, |
| 3680 | 3771 | }); |
| 3681 | 3772 | } |
| 3682 | yield* fs.writeFileString(macEntitlementsPath, renderMacPasskeyEntitlements(macPasskeySigning)); | |
| 3773 | macWebAuthn = resolveMacWebAuthnEntitlements( | |
| 3774 | yield* fs.readFileString(macPasskeySigning.provisioningProfilePath), | |
| 3775 | macPasskeySigning, | |
| 3776 | ); | |
| 3777 | yield* Effect.log( | |
| 3778 | `[desktop-artifact] In-app browser passkeys: Touch ID ${macWebAuthn.touchIdKeychainAccessGroup ? "enabled" : "disabled"}, browser passkeys ${macWebAuthn.browserPasskeys ? "enabled" : "disabled"}.`, | |
| 3779 | ); | |
| 3780 | yield* fs.writeFileString( | |
| 3781 | macEntitlementsPath, | |
| 3782 | renderMacPasskeyEntitlements(macPasskeySigning, macWebAuthn), | |
| 3783 | ); | |
| 3683 | 3784 | } |
| 3684 | 3785 | |
| 3685 | 3786 | // Windows splits dependencies per process: app.asar carries only the |
| ... | ... | @@ -3709,6 +3810,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( |
| 3709 | 3810 | version: appVersion, |
| 3710 | 3811 | buildVersion: appVersion, |
| 3711 | 3812 | t3codeCommitHash: commitHash, |
| 3813 | // Read by apps/desktop/src/preview/Passkeys.ts; must match the signed entitlements. | |
| 3814 | ...(macWebAuthn ? { t3codeWebAuthn: macWebAuthn } : {}), | |
| 3712 | 3815 | private: true, |
| 3713 | 3816 | packageManager: rootPackageJson.packageManager, |
| 3714 | 3817 | description: |
scripts/lib/desktop-external-packages.ts+1| ... | ... | @@ -16,6 +16,7 @@ export const DESKTOP_RUNTIME_EXTERNAL_PREFIXES = [ |
| 16 | 16 | "@napi-rs/keyring", |
| 17 | 17 | "@crowecawcaw/xa11y", |
| 18 | 18 | "@clerk/electron-passkeys", |
| 19 | "electron-webauthn", | |
| 19 | 20 | "ffi-rs", |
| 20 | 21 | "@yuuang/", |
| 21 | 22 | // Reads its own bundle from disk by resolving `playwright-core/package.json` |
scripts/package.json+2| ... | ... | @@ -13,11 +13,13 @@ |
| 13 | 13 | "@t3tools/shared": "workspace:*", |
| 14 | 14 | "@t3tools/tailscale": "workspace:*", |
| 15 | 15 | "effect": "catalog:", |
| 16 | "plist": "catalog:", | |
| 16 | 17 | "pngjs": "7.0.0", |
| 17 | 18 | "sharp": "0.35.4" |
| 18 | 19 | }, |
| 19 | 20 | "devDependencies": { |
| 20 | 21 | "@effect/vitest": "catalog:", |
| 22 | "@types/plist": "catalog:", | |
| 21 | 23 | "@types/pngjs": "6.0.5", |
| 22 | 24 | "typescript": "catalog:", |
| 23 | 25 | "typescript-legacy": "npm:typescript@~6.0.3", |
third-party-licenses.config.json+21| ... | ... | @@ -278,6 +278,27 @@ |
| 278 | 278 | "copyrights": ["Copyright (c) 2025 Stephen Crowe"] |
| 279 | 279 | } |
| 280 | 280 | }, |
| 281 | { | |
| 282 | "license": "MIT", | |
| 283 | "repositoryUrl": "https://github.com/iamEvanYT/electron-webauthn", | |
| 284 | "generatedNotice": { | |
| 285 | "licenseId": "MIT", | |
| 286 | "copyrights": ["Copyright (c) 2026 iamEvan"] | |
| 287 | } | |
| 288 | }, | |
| 289 | { | |
| 290 | "license": "MIT", | |
| 291 | "name": "@electron-webauthn/types", | |
| 292 | "sourceUrl": "https://github.com/iamEvanYT/electron-webauthn", | |
| 293 | "generatedNotice": { | |
| 294 | "licenseId": "MIT", | |
| 295 | "copyrights": ["Copyright (c) 2026 iamEvan"] | |
| 296 | } | |
| 297 | }, | |
| 298 | { | |
| 299 | "license": "MIT", | |
| 300 | "name": "objc-js" | |
| 301 | }, | |
| 281 | 302 | { |
| 282 | 303 | "license": "MIT", |
| 283 | 304 | "name": "map-stream", |