diff --git a/.github/workflows/adhoc-mac-build.yml b/.github/workflows/adhoc-mac-build.yml index 3e17eee9b68..a63080647f0 100644 --- a/.github/workflows/adhoc-mac-build.yml +++ b/.github/workflows/adhoc-mac-build.yml @@ -205,6 +205,18 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why: the Touch ID passkey authenticator needs a restricted keychain entitlement + # that only an embedded Developer ID provisioning profile can authorise. Without + # the profile the packager keeps the plain entitlements and passkeys stay off. + - name: Materialize macOS provisioning profile + if: env.MAC_PROVISIONING_PROFILE_BASE64 != '' + shell: bash + run: | + printf '%s' "$MAC_PROVISIONING_PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/orca.provisionprofile" + echo "ORCA_MAC_PROVISIONING_PROFILE=$RUNNER_TEMP/orca.provisionprofile" >> "$GITHUB_ENV" + env: + MAC_PROVISIONING_PROFILE_BASE64: ${{ secrets.MAC_PROVISIONING_PROFILE }} + # Why: signing is what makes an adhoc build installable over an existing # Orca, so a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/daily-mac-build.yml b/.github/workflows/daily-mac-build.yml index 45130e932d8..f0a378c64ea 100644 --- a/.github/workflows/daily-mac-build.yml +++ b/.github/workflows/daily-mac-build.yml @@ -176,6 +176,18 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why: the Touch ID passkey authenticator needs a restricted keychain entitlement + # that only an embedded Developer ID provisioning profile can authorise. Without + # the profile the packager keeps the plain entitlements and passkeys stay off. + - name: Materialize macOS provisioning profile + if: steps.freshness.outputs.should_build == 'true' && env.MAC_PROVISIONING_PROFILE_BASE64 != '' + shell: bash + run: | + printf '%s' "$MAC_PROVISIONING_PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/orca.provisionprofile" + echo "ORCA_MAC_PROVISIONING_PROFILE=$RUNNER_TEMP/orca.provisionprofile" >> "$GITHUB_ENV" + env: + MAC_PROVISIONING_PROFILE_BASE64: ${{ secrets.MAC_PROVISIONING_PROFILE }} + # Why: signing is what makes a daily installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/hourly-mac-build.yml b/.github/workflows/hourly-mac-build.yml index c300b2543b8..3b47e5269eb 100644 --- a/.github/workflows/hourly-mac-build.yml +++ b/.github/workflows/hourly-mac-build.yml @@ -182,6 +182,18 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why: the Touch ID passkey authenticator needs a restricted keychain entitlement + # that only an embedded Developer ID provisioning profile can authorise. Without + # the profile the packager keeps the plain entitlements and passkeys stay off. + - name: Materialize macOS provisioning profile + if: env.MAC_PROVISIONING_PROFILE_BASE64 != '' + shell: bash + run: | + printf '%s' "$MAC_PROVISIONING_PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/orca.provisionprofile" + echo "ORCA_MAC_PROVISIONING_PROFILE=$RUNNER_TEMP/orca.provisionprofile" >> "$GITHUB_ENV" + env: + MAC_PROVISIONING_PROFILE_BASE64: ${{ secrets.MAC_PROVISIONING_PROFILE }} + # Why: signing is what makes an hourly installable over an existing Orca, so # a missing cert must fail here rather than after a 20-minute build. - name: Verify macOS signing environment diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index f97ac0e5537..d9641f9e03f 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -72,6 +72,18 @@ jobs: retry_wait_seconds: 30 command: pnpm install --frozen-lockfile + # Why: the Touch ID passkey authenticator needs a restricted keychain entitlement + # that only an embedded Developer ID provisioning profile can authorise. Without + # the profile the packager keeps the plain entitlements and passkeys stay off. + - name: Materialize macOS provisioning profile + if: env.MAC_PROVISIONING_PROFILE_BASE64 != '' + shell: bash + run: | + printf '%s' "$MAC_PROVISIONING_PROFILE_BASE64" | base64 --decode > "$RUNNER_TEMP/orca.provisionprofile" + echo "ORCA_MAC_PROVISIONING_PROFILE=$RUNNER_TEMP/orca.provisionprofile" >> "$GITHUB_ENV" + env: + MAC_PROVISIONING_PROFILE_BASE64: ${{ secrets.MAC_PROVISIONING_PROFILE }} + - name: Verify macOS signing environment run: node config/scripts/verify-macos-release-env.mjs env: diff --git a/config/electron-builder.config.cjs b/config/electron-builder.config.cjs index 7e0009b3a24..c33189ad8f7 100644 --- a/config/electron-builder.config.cjs +++ b/config/electron-builder.config.cjs @@ -20,6 +20,7 @@ const { const { verifySkillsCliRuntime } = require('./scripts/verify-skills-cli-runtime.cjs') const { verifyStaticAppImagePackage } = require('./scripts/static-appimage-package-contract.cjs') const { signWindowsUninstallerViaSignPath } = require('./scripts/windows-uninstaller-signing.cjs') +const { resolveMacWebAuthnSigning } = require('./scripts/mac-webauthn-signing.cjs') // Why: dev-channel builds must carry the *release* identity — same bundle id, // Developer ID signature, and notarization ticket — or Squirrel.Mac refuses to @@ -64,6 +65,15 @@ const devChannelRepo = isHourlyChannel ? 'orca-adhoc' : null const appId = 'com.stablyai.orca' +const MAC_BASE_ENTITLEMENTS = 'resources/build/entitlements.mac.plist' +// Why: the Touch ID passkey authenticator needs a restricted keychain entitlement that +// only a provisioning profile can authorise; see scripts/mac-webauthn-signing.cjs. +const macWebAuthnSigning = resolveMacWebAuthnSigning({ + repoRoot: resolve(__dirname, '..'), + isMacRelease, + appId, + baseEntitlementsPath: MAC_BASE_ENTITLEMENTS +}) const featureWallResources = { from: 'resources/onboarding/feature-wall', to: 'onboarding/feature-wall' @@ -460,8 +470,9 @@ module.exports = { rank: 'Alternate' })), icon: 'resources/build/icon.icns', - entitlements: 'resources/build/entitlements.mac.plist', - entitlementsInherit: 'resources/build/entitlements.mac.plist', + entitlements: macWebAuthnSigning?.entitlements ?? MAC_BASE_ENTITLEMENTS, + entitlementsInherit: MAC_BASE_ENTITLEMENTS, + ...(macWebAuthnSigning ? { provisioningProfile: macWebAuthnSigning.provisioningProfile } : {}), extendInfo: { NSAppleEventsUsageDescription: 'Orca allows terminal-launched developer tools to automate local apps when you request it.', diff --git a/config/scripts/electron-builder-mac-channel-config.test.mjs b/config/scripts/electron-builder-mac-channel-config.test.mjs index dbd5a1170a1..12c03e3bd52 100644 --- a/config/scripts/electron-builder-mac-channel-config.test.mjs +++ b/config/scripts/electron-builder-mac-channel-config.test.mjs @@ -150,3 +150,73 @@ describe('electron-builder mac channel config', () => { }) }) }) + +describe('electron-builder mac passkey signing', () => { + const PROFILE_ENV = ['ORCA_MAC_PROVISIONING_PROFILE', 'APPLE_TEAM_ID'] + + function withProfileEnv(env, assert) { + const original = Object.fromEntries(PROFILE_ENV.map((key) => [key, process.env[key]])) + try { + for (const key of PROFILE_ENV) { + delete process.env[key] + } + withEnv(env, assert) + } finally { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) { + delete process.env[key] + } else { + process.env[key] = value + } + } + } + } + + // Why: keychain-access-groups is a restricted entitlement, and a binary that claims + // it without an embedded profile is killed at launch. No profile means no claim. + it('keeps the plain entitlements when no provisioning profile is supplied', () => { + withProfileEnv({ ORCA_MAC_RELEASE: '1', APPLE_TEAM_ID: 'ABCDE12345' }, (config) => { + expect(config.mac.entitlements).toBe('resources/build/entitlements.mac.plist') + expect(config.mac.provisioningProfile).toBeUndefined() + }) + }) + + it('signs release builds with the webauthn keychain group and embeds the profile', async () => { + const { mkdtemp, readFile, writeFile } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const dir = await mkdtemp(join(tmpdir(), 'orca-profile-')) + const profile = join(dir, 'orca.provisionprofile') + await writeFile(profile, 'profile') + let rendered + withProfileEnv( + { + ORCA_MAC_RELEASE: '1', + APPLE_TEAM_ID: 'ABCDE12345', + ORCA_MAC_PROVISIONING_PROFILE: profile + }, + (config) => { + expect(config.mac.provisioningProfile).toBe(profile) + expect(config.mac.entitlementsInherit).toBe('resources/build/entitlements.mac.plist') + rendered = config.mac.entitlements + } + ) + expect(await readFile(rendered, 'utf8')).toContain('ABCDE12345.com.stablyai.orca.webauthn') + }) + + it('never claims the keychain group on local builds', async () => { + const { mkdtemp, writeFile } = await import('node:fs/promises') + const { tmpdir } = await import('node:os') + const { join } = await import('node:path') + const dir = await mkdtemp(join(tmpdir(), 'orca-profile-')) + const profile = join(dir, 'orca.provisionprofile') + await writeFile(profile, 'profile') + withProfileEnv( + { APPLE_TEAM_ID: 'ABCDE12345', ORCA_MAC_PROVISIONING_PROFILE: profile }, + (config) => { + expect(config.mac.entitlements).toBe('resources/build/entitlements.mac.plist') + expect(config.mac.provisioningProfile).toBeUndefined() + } + ) + }) +}) diff --git a/config/scripts/mac-webauthn-signing.cjs b/config/scripts/mac-webauthn-signing.cjs new file mode 100644 index 00000000000..c5ea5ce22b0 --- /dev/null +++ b/config/scripts/mac-webauthn-signing.cjs @@ -0,0 +1,90 @@ +const { existsSync, mkdirSync, readFileSync, writeFileSync } = require('node:fs') +const { join, resolve } = require('node:path') + +// Why this exists: the Touch ID WebAuthn authenticator only works when the signed +// binary carries a `keychain-access-groups` entry for `..webauthn`, +// which `codesign` refuses to templatize (`$(TeamIdentifierPrefix)` is left +// verbatim), so the group is spliced in here from the team id at packaging time. +// The entry is restricted: macOS kills at launch any binary that claims it +// without an embedded provisioning profile authorising the group. Both inputs +// therefore travel together, and a build with neither keeps the base +// entitlements and simply never offers the authenticator. + +const WEBAUTHN_KEYCHAIN_GROUP_SUFFIX = '.webauthn' + +/** Inserts the identity and keychain-group keys before the closing ``. */ +function renderWebAuthnEntitlements(baseEntitlementsXml, { teamId, appId }) { + if (!/^[A-Z0-9]{10}$/.test(teamId)) { + throw new Error( + `APPLE_TEAM_ID must be a 10-character Apple team id, got ${JSON.stringify(teamId)}` + ) + } + if (baseEntitlementsXml.includes('keychain-access-groups')) { + throw new Error('base macOS entitlements must not already declare keychain-access-groups') + } + const closingIndex = baseEntitlementsXml.lastIndexOf('') + if (closingIndex === -1) { + throw new Error('base macOS entitlements plist has no closing ') + } + const inserted = [ + '\tcom.apple.application-identifier', + `\t${teamId}.${appId}`, + '\tcom.apple.developer.team-identifier', + `\t${teamId}`, + '\tkeychain-access-groups', + '\t', + `\t\t${teamId}.${appId}${WEBAUTHN_KEYCHAIN_GROUP_SUFFIX}`, + '\t', + '' + ].join('\n') + return ( + baseEntitlementsXml.slice(0, closingIndex) + inserted + baseEntitlementsXml.slice(closingIndex) + ) +} + +/** + * Resolves the mac signing inputs for one packaging run. + * Returns `null` when the build is not a signed release or the provisioning + * profile is absent, which keeps local and ad-hoc builds launchable. + */ +function resolveMacWebAuthnSigning({ + repoRoot, + isMacRelease, + appId, + baseEntitlementsPath, + env = process.env, + outputDir = join(repoRoot, 'out', 'mac-signing') +}) { + if (!isMacRelease) { + return null + } + const profilePath = env.ORCA_MAC_PROVISIONING_PROFILE + const teamId = env.APPLE_TEAM_ID + if (!profilePath || !teamId) { + return null + } + const absoluteProfilePath = resolve(repoRoot, profilePath) + if (!existsSync(absoluteProfilePath)) { + throw new Error( + `ORCA_MAC_PROVISIONING_PROFILE points at a missing file: ${absoluteProfilePath}` + ) + } + const entitlementsXml = renderWebAuthnEntitlements( + readFileSync(resolve(repoRoot, baseEntitlementsPath), 'utf8'), + { teamId, appId } + ) + mkdirSync(outputDir, { recursive: true }) + const entitlementsPath = join(outputDir, 'entitlements.mac.webauthn.plist') + writeFileSync(entitlementsPath, entitlementsXml, 'utf8') + return { + entitlements: entitlementsPath, + provisioningProfile: absoluteProfilePath, + keychainAccessGroup: `${teamId}.${appId}${WEBAUTHN_KEYCHAIN_GROUP_SUFFIX}` + } +} + +module.exports = { + WEBAUTHN_KEYCHAIN_GROUP_SUFFIX, + renderWebAuthnEntitlements, + resolveMacWebAuthnSigning +} diff --git a/config/scripts/mac-webauthn-signing.test.mjs b/config/scripts/mac-webauthn-signing.test.mjs new file mode 100644 index 00000000000..58c8337040c --- /dev/null +++ b/config/scripts/mac-webauthn-signing.test.mjs @@ -0,0 +1,134 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const { + renderWebAuthnEntitlements, + resolveMacWebAuthnSigning +} = require('./mac-webauthn-signing.cjs') + +const BASE_PLIST = ` + + +\tcom.apple.security.cs.allow-jit +\t + + +` + +const tempDirs = [] +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function makeRepo() { + const root = await mkdtemp(join(tmpdir(), 'orca-mac-signing-')) + tempDirs.push(root) + await writeFile(join(root, 'entitlements.mac.plist'), BASE_PLIST) + await writeFile(join(root, 'orca.provisionprofile'), 'profile-bytes') + return root +} + +describe('renderWebAuthnEntitlements', () => { + it('adds the team-scoped identity and webauthn keychain group to the base plist', () => { + const xml = renderWebAuthnEntitlements(BASE_PLIST, { + teamId: 'ABCDE12345', + appId: 'com.stablyai.orca' + }) + expect(xml).toContain('com.apple.security.cs.allow-jit') + expect(xml).toContain( + 'com.apple.application-identifier\n\tABCDE12345.com.stablyai.orca' + ) + expect(xml).toContain( + 'com.apple.developer.team-identifier\n\tABCDE12345' + ) + expect(xml).toContain('ABCDE12345.com.stablyai.orca.webauthn') + expect(xml.trimEnd().endsWith('')).toBe(true) + }) + + it('rejects a team id that is not a 10-character Apple team id', () => { + expect(() => + renderWebAuthnEntitlements(BASE_PLIST, { teamId: 'Lovecast LLC', appId: 'com.stablyai.orca' }) + ).toThrow(/APPLE_TEAM_ID/) + }) + + it('refuses to double-declare keychain-access-groups', () => { + const xml = renderWebAuthnEntitlements(BASE_PLIST, { teamId: 'ABCDE12345', appId: 'x' }) + expect(() => renderWebAuthnEntitlements(xml, { teamId: 'ABCDE12345', appId: 'x' })).toThrow( + /already declare/ + ) + }) +}) + +describe('resolveMacWebAuthnSigning', () => { + const baseOptions = (repoRoot, env) => ({ + repoRoot, + isMacRelease: true, + appId: 'com.stablyai.orca', + baseEntitlementsPath: 'entitlements.mac.plist', + outputDir: join(repoRoot, 'out'), + env + }) + + it('writes a rendered entitlements file and resolves the profile path', async () => { + const root = await makeRepo() + const signing = resolveMacWebAuthnSigning( + baseOptions(root, { + APPLE_TEAM_ID: 'ABCDE12345', + ORCA_MAC_PROVISIONING_PROFILE: 'orca.provisionprofile' + }) + ) + expect(signing).toEqual({ + entitlements: join(root, 'out', 'entitlements.mac.webauthn.plist'), + provisioningProfile: join(root, 'orca.provisionprofile'), + keychainAccessGroup: 'ABCDE12345.com.stablyai.orca.webauthn' + }) + expect(await readFile(signing.entitlements, 'utf8')).toContain( + 'ABCDE12345.com.stablyai.orca.webauthn' + ) + }) + + // Why: the restricted entitlement without its profile is a launch-time SIGKILL, + // so a release missing the profile must fall back to the plain entitlements. + it('returns null when the profile env is absent', async () => { + const root = await makeRepo() + expect(resolveMacWebAuthnSigning(baseOptions(root, { APPLE_TEAM_ID: 'ABCDE12345' }))).toBeNull() + }) + + it('returns null when the team id env is absent', async () => { + const root = await makeRepo() + expect( + resolveMacWebAuthnSigning( + baseOptions(root, { ORCA_MAC_PROVISIONING_PROFILE: 'orca.provisionprofile' }) + ) + ).toBeNull() + }) + + it('returns null for non-release builds even with every input present', async () => { + const root = await makeRepo() + expect( + resolveMacWebAuthnSigning({ + ...baseOptions(root, { + APPLE_TEAM_ID: 'ABCDE12345', + ORCA_MAC_PROVISIONING_PROFILE: 'orca.provisionprofile' + }), + isMacRelease: false + }) + ).toBeNull() + }) + + it('fails loudly when the profile path points nowhere', async () => { + const root = await makeRepo() + expect(() => + resolveMacWebAuthnSigning( + baseOptions(root, { + APPLE_TEAM_ID: 'ABCDE12345', + ORCA_MAC_PROVISIONING_PROFILE: 'missing.provisionprofile' + }) + ) + ).toThrow(/missing file/) + }) +}) diff --git a/docs/site/content/docs/browser/profiles.mdx b/docs/site/content/docs/browser/profiles.mdx index 102dd1e443d..19dc9748e0d 100644 --- a/docs/site/content/docs/browser/profiles.mdx +++ b/docs/site/content/docs/browser/profiles.mdx @@ -15,7 +15,15 @@ Browser-use profiles let you run the Orca browser with a specific identity — a Import cookies from Chrome or Edge (or from a cookie file) into a profile from Settings or the browser toolbar. Orca replaces existing cookies only for domains included in the import, so sign-ins for unrelated sites in the same profile stay intact. Google cookies are excluded — import menus show **Google logins aren't imported** and tell you to **Sign in to Google directly in Orca.** After an import that skipped Google cookies, a separate warning names the host that ran the import: open a browser in Orca on that host with the same profile, then sign in. -When a site requests a discoverable passkey from a USB security key and the key offers multiple accounts, Orca opens an account picker instead of silently canceling the sign-in. Choose the account you want or cancel the request. Platform passkeys stored by the operating system are not available in Orca yet; this flow is for external FIDO security keys. +## Passkeys + +Sites can sign you in with a passkey inside Orca's browser. What is available depends on the platform: + +- **macOS**: signed Orca builds offer a Touch ID passkey authenticator. When a site asks you to register a passkey, macOS shows the Touch ID sheet and stores the passkey in this Mac's Secure Enclave, scoped to the browser profile that created it. These passkeys are device-bound: they do not sync through iCloud Keychain, and passkeys you already keep in iCloud Keychain, Safari, or a password manager are not offered to sites in Orca. Register a new passkey for the site in Orca when it offers to, or use the site's other sign-in method. +- **Windows**: Windows handles passkey requests natively, so Windows Hello, security keys, and a phone via QR code all work as they do in other browsers. +- **Linux**: USB security keys only. + +USB security keys work on every platform. When a security key or Touch ID offers more than one account for a site, Orca opens an account picker instead of silently canceling the sign-in. Choose the account you want or cancel the request. ## Use a profile diff --git a/src/main/browser/browser-platform-passkeys-macos.test.ts b/src/main/browser/browser-platform-passkeys-macos.test.ts new file mode 100644 index 00000000000..d5901083603 --- /dev/null +++ b/src/main/browser/browser-platform-passkeys-macos.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it, vi } from 'vitest' +import { + enablePlatformPasskeys, + readWebAuthnKeychainAccessGroup +} from './browser-platform-passkeys-macos' + +const SIGNED_ENTITLEMENTS = ` + +com.apple.application-identifierTEAM123456.com.stablyai.orca +keychain-access-groups + + TEAM123456.com.stablyai.orca + TEAM123456.com.stablyai.orca.webauthn + +com.apple.security.cs.allow-jit +` + +type ConfigureWebAuthn = (options: { + touchID: { keychainAccessGroup: string; promptReason?: string } +}) => void + +function packagedApp(): { + isPackaged: boolean + configureWebAuthn: ReturnType> +} { + return { isPackaged: true, configureWebAuthn: vi.fn() } +} + +describe('readWebAuthnKeychainAccessGroup', () => { + it('returns the group ending in .webauthn from a signed entitlement plist', () => { + expect(readWebAuthnKeychainAccessGroup(SIGNED_ENTITLEMENTS)).toBe( + 'TEAM123456.com.stablyai.orca.webauthn' + ) + }) + + it('returns null when the array holds no webauthn group', () => { + const xml = SIGNED_ENTITLEMENTS.replace( + 'TEAM123456.com.stablyai.orca.webauthn', + '' + ) + expect(readWebAuthnKeychainAccessGroup(xml)).toBeNull() + }) + + it('returns null for an unsigned binary whose entitlement dump is empty', () => { + expect(readWebAuthnKeychainAccessGroup('')).toBeNull() + }) +}) + +describe('enablePlatformPasskeys', () => { + it('configures the Touch ID authenticator with the signed group and a prompt reason', () => { + const app = packagedApp() + const outcome = enablePlatformPasskeys(app, { + platform: 'darwin', + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + readEntitlements: () => SIGNED_ENTITLEMENTS + }) + expect(outcome).toEqual({ + status: 'enabled', + keychainAccessGroup: 'TEAM123456.com.stablyai.orca.webauthn' + }) + expect(app.configureWebAuthn).toHaveBeenCalledWith({ + touchID: { + keychainAccessGroup: 'TEAM123456.com.stablyai.orca.webauthn', + promptReason: 'sign in to $1' + } + }) + }) + + it('reads the entitlements of the running executable', () => { + const readEntitlements = vi.fn(() => SIGNED_ENTITLEMENTS) + enablePlatformPasskeys(packagedApp(), { + platform: 'darwin', + execPath: '/Applications/Orca.app/Contents/MacOS/Orca', + readEntitlements + }) + expect(readEntitlements).toHaveBeenCalledWith('/Applications/Orca.app/Contents/MacOS/Orca') + }) + + it.each(['win32', 'linux'] as const)('does nothing on %s', (platform) => { + const app = packagedApp() + const readEntitlements = vi.fn(() => SIGNED_ENTITLEMENTS) + expect(enablePlatformPasskeys(app, { platform, readEntitlements })).toEqual({ + status: 'skipped', + reason: 'not-macos' + }) + expect(readEntitlements).not.toHaveBeenCalled() + expect(app.configureWebAuthn).not.toHaveBeenCalled() + }) + + it('skips unpackaged runs without inspecting the shared Electron binary', () => { + const app = { ...packagedApp(), isPackaged: false } + const readEntitlements = vi.fn(() => SIGNED_ENTITLEMENTS) + expect(enablePlatformPasskeys(app, { platform: 'darwin', readEntitlements })).toEqual({ + status: 'skipped', + reason: 'unpackaged' + }) + expect(readEntitlements).not.toHaveBeenCalled() + expect(app.configureWebAuthn).not.toHaveBeenCalled() + }) + + // Why: a build signed without the group would make Chromium log an entitlement + // error on every request; leaving the authenticator unconfigured keeps today's behavior. + it('skips a signed build that lacks the webauthn keychain group', () => { + const app = packagedApp() + expect( + enablePlatformPasskeys(app, { + platform: 'darwin', + readEntitlements: () => '' + }) + ).toEqual({ status: 'skipped', reason: 'no-entitlement' }) + expect(app.configureWebAuthn).not.toHaveBeenCalled() + }) + + it('skips when the running Electron has no configureWebAuthn', () => { + const app = { isPackaged: true } + expect( + enablePlatformPasskeys(app, { + platform: 'darwin', + readEntitlements: () => SIGNED_ENTITLEMENTS + }) + ).toEqual({ status: 'skipped', reason: 'api-unavailable' }) + }) + + it('reports a codesign read failure without throwing', () => { + const app = packagedApp() + expect( + enablePlatformPasskeys(app, { + platform: 'darwin', + readEntitlements: () => { + throw new Error('codesign exited 1') + } + }) + ).toEqual({ status: 'failed', error: 'codesign exited 1' }) + expect(app.configureWebAuthn).not.toHaveBeenCalled() + }) + + it('reports a configureWebAuthn failure without throwing', () => { + const app = packagedApp() + app.configureWebAuthn.mockImplementation(() => { + throw new TypeError('bad options') + }) + expect( + enablePlatformPasskeys(app, { + platform: 'darwin', + readEntitlements: () => SIGNED_ENTITLEMENTS + }) + ).toEqual({ status: 'failed', error: 'bad options' }) + }) +}) + +describe('startup wiring', () => { + // Why: configureWebAuthn is process-wide and must land before the first guest can + // issue a passkey request, so it sits ahead of browser session initialization. + it('enables platform passkeys before browser sessions initialize', async () => { + const { readFileSync } = await import('node:fs') + const { join } = await import('node:path') + const source = readFileSync( + join(import.meta.dirname, '../startup/main-process-ready-foundation.ts'), + 'utf8' + ) + const enableIndex = source.indexOf('enablePlatformPasskeys(app)') + const sessionsIndex = source.indexOf('initializeBrowserSessionsForApp({') + expect(enableIndex).toBeGreaterThan(-1) + expect(sessionsIndex).toBeGreaterThan(enableIndex) + }) +}) diff --git a/src/main/browser/browser-platform-passkeys-macos.ts b/src/main/browser/browser-platform-passkeys-macos.ts new file mode 100644 index 00000000000..cf3e3d441d0 --- /dev/null +++ b/src/main/browser/browser-platform-passkeys-macos.ts @@ -0,0 +1,97 @@ +import { runProcessSync } from '../../shared/child-process/run-process' + +// Why: GitHub and other relying parties gate passkey sign-in on +// PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(). Electron +// reports false until the app opts into the Touch ID authenticator, and a +// discoverable-credential request then waits on USB security keys for the +// three-minute Chromium floor. Opting in makes the check true, shows the Touch +// ID sheet, and lets a request with no matching passkey fail promptly. + +export const ORCA_WEBAUTHN_KEYCHAIN_GROUP_SUFFIX = '.webauthn' + +type PlatformPasskeyApp = { + isPackaged: boolean + configureWebAuthn?: (options: { + touchID: { keychainAccessGroup: string; promptReason?: string } + }) => void +} + +export type PlatformPasskeyOutcome = + | { status: 'enabled'; keychainAccessGroup: string } + | { status: 'skipped'; reason: 'not-macos' | 'unpackaged' | 'api-unavailable' | 'no-entitlement' } + | { status: 'failed'; error: string } + +/** + * Picks the WebAuthn keychain group out of the executable's signed entitlements. + * Why read the signature and not a build constant: the Touch ID authenticator only + * works when the running binary actually carries the group, so the signature is the + * single source of truth and unsigned local builds stay inert automatically. + */ +export function readWebAuthnKeychainAccessGroup(entitlementsXml: string): string | null { + const groupsMatch = /keychain-access-groups<\/key>\s*([\s\S]*?)<\/array>/.exec( + entitlementsXml + ) + if (!groupsMatch) { + return null + } + for (const match of groupsMatch[1].matchAll(/([^<]*)<\/string>/g)) { + const group = match[1].trim() + if (group.endsWith(ORCA_WEBAUTHN_KEYCHAIN_GROUP_SUFFIX)) { + return group + } + } + return null +} + +function readSignedEntitlements(execPath: string): string { + // Why: `codesign -d --entitlements :-` prints the signed entitlement plist to stdout. + const result = runProcessSync({ + program: 'codesign', + args: ['-d', '--entitlements', ':-', execPath], + timeoutMs: 10_000 + }) + if (result.code !== 0) { + throw new Error(`codesign exited ${result.code ?? result.signal}: ${result.stderr.trim()}`) + } + return result.stdout +} + +export function enablePlatformPasskeys( + app: PlatformPasskeyApp, + options: { + platform?: NodeJS.Platform + execPath?: string + readEntitlements?: (execPath: string) => string + } = {} +): PlatformPasskeyOutcome { + if ((options.platform ?? process.platform) !== 'darwin') { + return { status: 'skipped', reason: 'not-macos' } + } + if (!app.isPackaged) { + return { status: 'skipped', reason: 'unpackaged' } + } + if (typeof app.configureWebAuthn !== 'function') { + return { status: 'skipped', reason: 'api-unavailable' } + } + let keychainAccessGroup: string | null + try { + keychainAccessGroup = readWebAuthnKeychainAccessGroup( + (options.readEntitlements ?? readSignedEntitlements)(options.execPath ?? process.execPath) + ) + } catch (error) { + return { status: 'failed', error: error instanceof Error ? error.message : String(error) } + } + if (!keychainAccessGroup) { + return { status: 'skipped', reason: 'no-entitlement' } + } + try { + // Why the explicit reason: Electron's default reads from a locale pak that can be + // missing, and an empty reason crashes the LAContext prompt (electron#53185). + app.configureWebAuthn({ + touchID: { keychainAccessGroup, promptReason: 'sign in to $1' } + }) + } catch (error) { + return { status: 'failed', error: error instanceof Error ? error.message : String(error) } + } + return { status: 'enabled', keychainAccessGroup } +} diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 3c0e01fe09e..b7144b826d5 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -39,6 +39,7 @@ import { import { installDocPreviewProtocolHandler } from '../browser/doc-preview-protocol' import { registerDocPreviewGrantHandlers } from '../ipc/doc-preview-grant-ipc' import { initializeBrowserSessionsForApp } from '../browser/browser-session-startup' +import { enablePlatformPasskeys } from '../browser/browser-platform-passkeys-macos' import { browserSessionRegistry } from '../browser/browser-session-registry' import { logStartupMilestone } from './startup-diagnostics' import { writeHttp1CompatibilityMarker } from './http1-compatibility-marker' @@ -267,6 +268,13 @@ export async function initializeReadyFoundation(): Promise { // Why: the preview session is protocol-scoped, so the handler must exist before any preview webview attaches. installDocPreviewProtocolHandler() registerDocPreviewGrantHandlers() + // Why here: configureWebAuthn is process-wide and must run after `ready`; before any guest can issue a passkey request. + const platformPasskeys = enablePlatformPasskeys(app) + if (platformPasskeys.status === 'failed') { + console.warn('[browser] Touch ID passkey authenticator unavailable:', platformPasskeys.error) + } else if (platformPasskeys.status === 'enabled') { + console.log('[browser] Touch ID passkey authenticator enabled') + } // Why: browser sessions serve desktop webviews and runtime profile commands, so init at app startup rather than via a renderer IPC path. initializeBrowserSessionsForApp({ orcaProfileId: profile.profile.id,