mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
feat(browser): enable the Touch ID passkey authenticator on signed macOS builds
Sites gate passkey sign-in on isUserVerifyingPlatformAuthenticatorAvailable(), which Electron reports as false until the app opts into the Touch ID authenticator. GitHub's two-factor page then shows 'partial passkey support' and the credential request waits on USB keys for Chromium's three-minute floor. Configure the authenticator after ready from the keychain group in the signed entitlements, render that entitlement at packaging time from APPLE_TEAM_ID, and embed the Developer ID provisioning profile that macOS requires before it will launch a binary claiming keychain-access-groups. Builds without the profile keep the plain entitlements and today's behavior.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 `<TEAM>.<bundle>.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 `</dict>`. */
|
||||
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('</dict>')
|
||||
if (closingIndex === -1) {
|
||||
throw new Error('base macOS entitlements plist has no closing </dict>')
|
||||
}
|
||||
const inserted = [
|
||||
'\t<key>com.apple.application-identifier</key>',
|
||||
`\t<string>${teamId}.${appId}</string>`,
|
||||
'\t<key>com.apple.developer.team-identifier</key>',
|
||||
`\t<string>${teamId}</string>`,
|
||||
'\t<key>keychain-access-groups</key>',
|
||||
'\t<array>',
|
||||
`\t\t<string>${teamId}.${appId}${WEBAUTHN_KEYCHAIN_GROUP_SUFFIX}</string>`,
|
||||
'\t</array>',
|
||||
''
|
||||
].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
|
||||
}
|
||||
@@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
\t<key>com.apple.security.cs.allow-jit</key>
|
||||
\t<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
`
|
||||
|
||||
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('<key>com.apple.security.cs.allow-jit</key>')
|
||||
expect(xml).toContain(
|
||||
'<key>com.apple.application-identifier</key>\n\t<string>ABCDE12345.com.stablyai.orca</string>'
|
||||
)
|
||||
expect(xml).toContain(
|
||||
'<key>com.apple.developer.team-identifier</key>\n\t<string>ABCDE12345</string>'
|
||||
)
|
||||
expect(xml).toContain('<string>ABCDE12345.com.stablyai.orca.webauthn</string>')
|
||||
expect(xml.trimEnd().endsWith('</plist>')).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/)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
enablePlatformPasskeys,
|
||||
readWebAuthnKeychainAccessGroup
|
||||
} from './browser-platform-passkeys-macos'
|
||||
|
||||
const SIGNED_ENTITLEMENTS = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0"><dict>
|
||||
<key>com.apple.application-identifier</key><string>TEAM123456.com.stablyai.orca</string>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>TEAM123456.com.stablyai.orca</string>
|
||||
<string>TEAM123456.com.stablyai.orca.webauthn</string>
|
||||
</array>
|
||||
<key>com.apple.security.cs.allow-jit</key><true/>
|
||||
</dict></plist>`
|
||||
|
||||
type ConfigureWebAuthn = (options: {
|
||||
touchID: { keychainAccessGroup: string; promptReason?: string }
|
||||
}) => void
|
||||
|
||||
function packagedApp(): {
|
||||
isPackaged: boolean
|
||||
configureWebAuthn: ReturnType<typeof vi.fn<ConfigureWebAuthn>>
|
||||
} {
|
||||
return { isPackaged: true, configureWebAuthn: vi.fn<ConfigureWebAuthn>() }
|
||||
}
|
||||
|
||||
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(
|
||||
'<string>TEAM123456.com.stablyai.orca.webauthn</string>',
|
||||
''
|
||||
)
|
||||
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: () => '<plist version="1.0"><dict></dict></plist>'
|
||||
})
|
||||
).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)
|
||||
})
|
||||
})
|
||||
@@ -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 = /<key>keychain-access-groups<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(
|
||||
entitlementsXml
|
||||
)
|
||||
if (!groupsMatch) {
|
||||
return null
|
||||
}
|
||||
for (const match of groupsMatch[1].matchAll(/<string>([^<]*)<\/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 }
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user