From 636eaf8f2990c4a30b4f112f95f68a0650bff999 Mon Sep 17 00:00:00 2001 From: Laurin-Notemann <47634664+Laurin-Notemann@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:04:00 +0000 Subject: [PATCH] fix(otter): provision macOS browser passkey releases Restore the signing hunks from upstream 3e01c4bc57, 2f7616ef17, and 454b94a13a, which are already in the branch ancestry. Keep Otter account policy in scripts/otter and regenerate workflow adaptations through the fork adapter. Make the profile optional, select only used profile grants, preserve hardened-runtime entitlements, and publish matching WebAuthn runtime metadata. Add profile fixtures and document the separate Otterware secret and pending Apple capability. Co-Authored-By: Claude Opus 5.5 --- .github/scripts/otter-adapt-upstream.sh | 46 ++++ .github/workflows/release-desktop.yml | 19 ++ OTTER.md | 11 + docs/operations/release.md | 17 +- pnpm-lock.yaml | 20 ++ pnpm-workspace.yaml | 2 + scripts/build-desktop-artifact.test.ts | 20 ++ scripts/build-desktop-artifact.ts | 83 ++++++- scripts/otter/fixtures/macos-profile.plist | 20 ++ scripts/otter/macos-profile.test.ts | 206 +++++++++++++++++ scripts/otter/macos-profile.ts | 247 +++++++++++++++++++++ scripts/package.json | 2 + 12 files changed, 684 insertions(+), 9 deletions(-) create mode 100644 scripts/otter/fixtures/macos-profile.plist create mode 100644 scripts/otter/macos-profile.test.ts create mode 100644 scripts/otter/macos-profile.ts diff --git a/.github/scripts/otter-adapt-upstream.sh b/.github/scripts/otter-adapt-upstream.sh index 53a986a43aef..61d91542e969 100755 --- a/.github/scripts/otter-adapt-upstream.sh +++ b/.github/scripts/otter-adapt-upstream.sh @@ -80,6 +80,52 @@ preview_path.write_text(adapt_job(preview_path.read_text(), 'deploy', 'stage-pre ])) PY +# Keep upstream's provisioning-profile path, but make it optional for Otter's +# existing Developer ID releases. Otter Accounts has no Clerk RP domain. +python3 - <<'PY' +from pathlib import Path + +path = Path('.github/workflows/release-desktop.yml') +text = path.read_text() +if ' MACOS_PROVISIONING_PROFILE:\n' not in text: + text = text.replace(' AZURE_TENANT_ID:\n', + ' MACOS_PROVISIONING_PROFILE:\n required: false\n AZURE_TENANT_ID:\n', 1) +if ' MACOS_PROVISIONING_PROFILE:' not in text: + text = text.replace(' APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}\n', + ' APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }}\n' + ' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n', 1) +text = text.replace(' T3CODE_CLERK_PASSKEY_RP_DOMAINS: ${{ vars.CLERK_PASSKEY_RP_DOMAINS }}\n', '') +if ' T3CODE_MACOS_PASSKEY_RP_DOMAINS:' not in text: + text = text.replace(' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n', + ' MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }}\n' + ' T3CODE_MACOS_PASSKEY_RP_DOMAINS: ${{ vars.MACOS_PASSKEY_RP_DOMAINS }}\n', 1) +start_anchor = ' if has_all "$CSC_LINK" "$CSC_KEY_PASSWORD" "$APPLE_API_KEY" "$APPLE_API_KEY_ID" "$APPLE_API_ISSUER"; then\n' +end_anchor = ' echo "macOS signing enabled."\n' +start = text.index(start_anchor) + len(start_anchor) +end = text.index(end_anchor, start) +text = text[:start] + ''' key_path="$RUNNER_TEMP/AuthKey_${APPLE_API_KEY_ID}.p8" + printf '%s' "$APPLE_API_KEY" > "$key_path" + export APPLE_API_KEY="$key_path" + + if [[ -n "$MACOS_PROVISIONING_PROFILE" ]]; then + if [[ -z "$APPLE_TEAM_ID" ]]; then + echo "MACOS_PROVISIONING_PROFILE requires APPLE_TEAM_ID." >&2 + exit 1 + fi + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + echo "macOS provisioning profile validated; entitlements selected by the packager." + else + echo "No macOS provisioning profile; signing and notarization continue without passkey entitlements." + fi + +''' + text[end:] +path.write_text(text) +PY + # Point agents at the fork guide from AGENTS.md, which every agent reads first. perl -0pi -e ' s/\A(# [^\n]*\n)/$1\n> **This is the Otter Code fork.** Read [OTTER.md](OTTER.md) first: it covers how the fork\n> stays in sync with upstream, its identities and infrastructure, releases, and moving machines.\n/ diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index abdd018b144a..1d0dc9696dfe 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -19,6 +19,8 @@ on: required: false APPLE_API_ISSUER: required: false + MACOS_PROVISIONING_PROFILE: + required: false AZURE_TENANT_ID: required: false AZURE_CLIENT_ID: @@ -376,6 +378,8 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} APPLE_TEAM_ID: ${{ vars.APPLE_TEAM_ID }} + MACOS_PROVISIONING_PROFILE: ${{ secrets.MACOS_PROVISIONING_PROFILE }} + T3CODE_MACOS_PASSKEY_RP_DOMAINS: ${{ vars.MACOS_PASSKEY_RP_DOMAINS }} AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} @@ -408,6 +412,21 @@ jobs: printf '%s' "$APPLE_API_KEY" > "$key_path" export APPLE_API_KEY="$key_path" + if [[ -n "$MACOS_PROVISIONING_PROFILE" ]]; then + if [[ -z "$APPLE_TEAM_ID" ]]; then + echo "MACOS_PROVISIONING_PROFILE requires APPLE_TEAM_ID." >&2 + exit 1 + fi + profile_path="$RUNNER_TEMP/t3code.provisionprofile" + printf '%s' "$MACOS_PROVISIONING_PROFILE" | base64 -D > "$profile_path" + security cms -D -i "$profile_path" >/dev/null + export T3CODE_APPLE_TEAM_ID="$APPLE_TEAM_ID" + export T3CODE_MACOS_PROVISIONING_PROFILE="$profile_path" + echo "macOS provisioning profile validated; entitlements selected by the packager." + else + echo "No macOS provisioning profile; signing and notarization continue without passkey entitlements." + fi + echo "macOS signing enabled." args+=(--signed) else diff --git a/OTTER.md b/OTTER.md index 888f0deaf69e..3ca5a62f51d7 100644 --- a/OTTER.md +++ b/OTTER.md @@ -182,6 +182,17 @@ widget's `T3Mark.svg`, `agent_activity_mark.xml`, the DMG backgrounds) is the ki packages with the `NPM_TOKEN` secret (an npm account that is a member of the `otterware` org), tagged `nightly` and also `latest`, so `npx otter-code` and `npm i -g otter-code` get the newest build. The token is needed because new packages have no npm trusted publisher yet. +- **macOS passkeys:** `MACOS_PROVISIONING_PROFILE` is the base64 Developer ID profile for + `dev.otterware.code`, signed by Clary GmbH's team `YNJ5WLH965` (`APPLE_TEAM_ID`). The + Otterware branch must pass its own `OTTERWARE_MACOS_PROVISIONING_PROFILE` secret into the + reusable workflow's `MACOS_PROVISIONING_PROFILE` input and use app ID `dev.otterware.suite`. + The packager derives the WebAuthn keychain group from the team and app IDs and claims only + used profile grants. Apple's managed `com.apple.developer.web-browser.public-key-credential` + capability is pending approval; after approval, regenerate both profiles and update their + secrets. Associated Domains are optional (`MACOS_PASSKEY_RP_DOMAINS`, comma-separated) and + unnecessary for browser passkeys; no Clerk key is required. Without a profile, existing + signing and notarization continue. Otter's policy lives in `scripts/otter/macos-profile.ts`; + regenerate workflow adaptations with `.github/scripts/otter-adapt-upstream.sh`. - **Hosted web:** the release stages a Cloudflare Worker version, then promotes it after the desktop/CLI release publishes. `scripts/otter/web/` owns its routing and static assets; the adaptation script keeps upstream's Vercel path separate. The `Deploy Otter Code web` diff --git a/docs/operations/release.md b/docs/operations/release.md index b9984417f776..ea14989a2630 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -524,7 +524,6 @@ Required secrets used by the workflow: - `APPLE_API_KEY` - `APPLE_API_KEY_ID` - `APPLE_API_ISSUER` -- `MACOS_PROVISIONING_PROFILE` (base64-encoded provisioning profile with Associated Domains) Required repository variables: @@ -532,15 +531,19 @@ Required repository variables: Optional repository variables: -domain from the production Otter Accounts issuer. +- `MACOS_PASSKEY_RP_DOMAINS`: explicitly configured, comma-separated Associated Domains. + Browser passkeys need none. The profile must authorize each configured domain. + +Optional secret: `MACOS_PROVISIONING_PROFILE`, a base64-encoded Developer ID profile. Without +it, signing and notarization continue with the existing hardened-runtime entitlements. Checklist: 1. Apple Developer account access: - Team has rights to create Developer ID certificates. -2. Create an explicit App ID for `com.t3tools.t3code` and enable Associated Domains. +2. Create an explicit App ID for `dev.otterware.code`; enable Associated Domains only if used. 3. Create a `Developer ID Application` certificate and a compatible provisioning profile for that - App ID with Associated Domains enabled. + App ID with the passkey capabilities Apple has granted (see [OTTER.md](../../OTTER.md)). 4. Export the certificate + private key as `.p12` from Keychain. 5. Base64-encode the `.p12` and store as `CSC_LINK`. 6. Base64-encode the provisioning profile and store it as `MACOS_PROVISIONING_PROFILE`. @@ -551,8 +554,8 @@ Checklist: - `APPLE_API_KEY`: contents of the downloaded `.p8` - `APPLE_API_KEY_ID`: Key ID - `APPLE_API_ISSUER`: Issuer ID -10. Re-run a tag release and confirm macOS artifacts are signed/notarized and contain the expected - `com.apple.developer.associated-domains` entitlement. +10. Re-run a release and confirm macOS artifacts are signed/notarized and contain only the + configured, profile-authorized passkey entitlements reported by the packager. Notes: @@ -563,7 +566,7 @@ Notes: - In-app browser passkeys depend on the same profile. The packager adds each entitlement only when the profile grants it, because macOS will not launch an app that claims more than its profile allows. The build log reports which ones it enabled. - - `keychain-access-groups` (`.com.t3tools.t3code.webauthn`) enables Touch ID passkeys. + - `keychain-access-groups` (`..webauthn`) enables Touch ID passkeys. Profiles that grant the team's keychain groups (`.*`) cover it. - `com.apple.developer.web-browser.public-key-credential` lets the system passkey sheet (iCloud Keychain, password managers, phones, security keys) serve any site. Apple grants it as a diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c7f162024cf..5ade8a467da3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,6 +6,9 @@ settings: catalogs: default: + '@types/plist': + specifier: 3.0.5 + version: 3.0.5 '@effect/openapi-generator': specifier: 4.0.2 version: 4.0.2 @@ -45,6 +48,9 @@ catalogs: micromark-util-types: specifier: 2.0.2 version: 2.0.2 + plist: + specifier: 3.1.1 + version: 3.1.1 rehype-raw: specifier: 7.0.0 version: 7.0.0 @@ -1841,6 +1847,9 @@ importers: effect: specifier: 4.0.2 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688) + plist: + specifier: 'catalog:' + version: 3.1.1 pngjs: specifier: 7.0.0 version: 7.0.0 @@ -1851,6 +1860,9 @@ importers: '@effect/vitest': specifier: 4.0.2 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) + '@types/plist': + specifier: 'catalog:' + version: 3.0.5 '@types/pngjs': specifier: 6.0.5 version: 6.0.5 @@ -6293,6 +6305,9 @@ packages: '@types/node@24.12.4': resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + '@types/plist@3.0.5': + resolution: {integrity: sha512-E6OCaRmAe4WDmWNsL/9RMqdkkzDCY1etutkflWk4c+AcjDU07Pcz1fQwTX0TQz+Pxqn9i4L1TU3UFpjnrcDgxA==} + '@types/pngjs@6.0.5': resolution: {integrity: sha512-0k5eKfrA83JOZPppLtS2C7OUtyNAl2wKNxfyYl9Q5g9lPkgBl/9hNyAu6HuEH2J4XmIv2znEpkDd0SaZVxW6iQ==} @@ -16660,6 +16675,11 @@ snapshots: dependencies: undici-types: 7.16.0 + '@types/plist@3.0.5': + dependencies: + '@types/node': 24.12.4 + xmlbuilder: 15.1.1 + '@types/pngjs@6.0.5': dependencies: '@types/node': 24.12.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 319c94ee49ad..9a48ed6bf6fb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -44,12 +44,14 @@ catalog: "@tailwindcss/oxide": 4.3.3 "@tailwindcss/vite": 4.3.3 "@types/node": 24.12.4 + "@types/plist": 3.0.5 "@types/ws": 8.18.2 effect: 4.0.2 electron-webauthn: 1.3.1 jose: 6.2.2 lightningcss: 1.33.0 objc-js: 1.5.0 + plist: 3.1.1 tailwindcss: 4.3.3 tldts: 7.4.2 typescript: 7.0.2 diff --git a/scripts/build-desktop-artifact.test.ts b/scripts/build-desktop-artifact.test.ts index d6a186fba50a..830d0f62f1bb 100644 --- a/scripts/build-desktop-artifact.test.ts +++ b/scripts/build-desktop-artifact.test.ts @@ -1950,6 +1950,26 @@ it.layer(NodeServices.layer)("build-desktop-artifact", (it) => { ), ); + it.effect("keeps default signing when no macOS provisioning profile is configured", () => + Effect.gen(function* () { + const config = yield* createBuildConfig( + "mac", + "dmg", + "1.2.3", + true, + false, + undefined, + undefined, + ); + const mac = config.mac as Record; + assert.notProperty(mac, "entitlements"); + assert.notProperty(mac, "provisioningProfile"); + // electron-builder enables hardened runtime by default. + assert.notProperty(mac, "hardenedRuntime"); + assert.match(String(mac.sign), /[\\/]scripts[\\/]sign-macos\.ts$/); + }).pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: {} })))), + ); + it.effect("adds signing entitlements and both renderer protocols to signed macOS builds", () => Effect.gen(function* () { const config = yield* createBuildConfig("mac", "dmg", "1.2.3", true, false, undefined, { diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index dc44db1cee3b..925be4b6641b 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -4,7 +4,6 @@ import * as NodeFSP from "node:fs/promises"; import * as NodeCrypto from "node:crypto"; import * as NodeModule from "node:module"; -// plist is CommonJS; Node cannot load its named exports from an ES module. import { createPackageWithOptions, @@ -34,6 +33,13 @@ import { selectCliRuntimeExternalDependencies, } from "./lib/cli-external-packages.ts"; import { selectDesktopRuntimeExternalDependencies } from "./lib/desktop-external-packages.ts"; +import { loadRepoEnv } from "./lib/public-config.ts"; +import { + resolveMacPasskeySigningConfiguration, + resolveMacWebAuthnEntitlements, + renderMacPasskeyEntitlements, + type MacWebAuthnEntitlements, +} from "./otter/macos-profile.ts"; import { resolveCatalogDependencies } from "./lib/resolve-catalog.ts"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; @@ -196,6 +202,28 @@ const getDefaultArch = Effect.fn("getDefaultArch")(function* (platform: typeof B return yield* getDefaultBuildArch(platform, config); }); +export class MacPasskeySigningConfigurationResolutionError extends Schema.TaggedError()( + "MacPasskeySigningConfigurationResolutionError", + { + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Failed to resolve macOS passkey signing configuration."; + } +} + +export class MacProvisioningProfileNotFoundError extends Schema.TaggedError()( + "MacProvisioningProfileNotFoundError", + { + provisioningProfilePath: Schema.String, + }, +) { + override get message(): string { + return `macOS provisioning profile not found: ${this.provisioningProfilePath}`; + } +} + export class KeyringNativePackageMissingError extends Schema.TaggedError()( "KeyringNativePackageMissingError", { @@ -907,6 +935,7 @@ interface StagePackageJson { readonly version: string; readonly buildVersion: string; readonly t3codeCommitHash: string; + readonly t3codeWebAuthn?: MacWebAuthnEntitlements; readonly private: true; readonly packageManager: string; readonly description: string; @@ -3440,6 +3469,49 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( const stageProdResourcesDir = path.join(stageAppDir, "apps/desktop/prod-resources"); yield* fs.copy(stageResourcesDir, stageProdResourcesDir); + const configuredMacPasskeySigning = + options.platform === "mac" && options.signed + ? yield* Effect.try({ + try: () => + resolveMacPasskeySigningConfiguration(loadRepoEnv({ repoRoot }), DESKTOP_APP_ID), + catch: (cause) => new MacPasskeySigningConfigurationResolutionError({ cause }), + }) + : undefined; + const macPasskeySigning = configuredMacPasskeySigning + ? { + ...configuredMacPasskeySigning, + provisioningProfilePath: path.resolve( + repoRoot, + configuredMacPasskeySigning.provisioningProfilePath, + ), + } + : undefined; + const macEntitlementsPath = macPasskeySigning + ? path.join(stageAppDir, "entitlements.mac.plist") + : undefined; + let macWebAuthn: MacWebAuthnEntitlements | undefined; + if (macPasskeySigning && macEntitlementsPath) { + if (!(yield* fs.exists(macPasskeySigning.provisioningProfilePath))) { + return yield* new MacProvisioningProfileNotFoundError({ + provisioningProfilePath: macPasskeySigning.provisioningProfilePath, + }); + } + const profile = yield* fs.readFileString(macPasskeySigning.provisioningProfilePath); + macWebAuthn = resolveMacWebAuthnEntitlements(profile, macPasskeySigning); + yield* Effect.log( + `[desktop-artifact] In-app browser passkeys: Touch ID ${macWebAuthn.touchIdKeychainAccessGroup ? "enabled" : "disabled"}, browser passkeys ${macWebAuthn.browserPasskeys ? "enabled" : "disabled"}.`, + ); + const entitlements = renderMacPasskeyEntitlements(macPasskeySigning, macWebAuthn, profile); + yield* fs.writeFileString(macEntitlementsPath, entitlements); + yield* Effect.log( + `[desktop-artifact] macOS provisioning profile embedded; enabled entitlements: ${[...entitlements.matchAll(/([^<]+)<\/key>/gu)].map((match) => match[1]).join(", ")}.`, + ); + } else if (options.platform === "mac") { + yield* Effect.log( + "[desktop-artifact] No macOS provisioning profile embedded; passkey entitlements disabled; default hardened-runtime entitlements retained.", + ); + } + // Windows splits dependencies per process: app.asar carries only the // desktop main-process externals, while the server bundle's externals live // in the server.asar sidecar (see stageWindowsServerSidecar). macOS and @@ -3467,6 +3539,8 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( version: appVersion, buildVersion: appVersion, t3codeCommitHash: commitHash, + // Read by apps/desktop/src/preview/Passkeys.ts; must match the signed entitlements. + ...(macWebAuthn ? { t3codeWebAuthn: macWebAuthn } : {}), private: true, packageManager: rootPackageJson.packageManager, description: @@ -3483,7 +3557,12 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( options.signed, options.mockUpdates, options.mockUpdateServerPort, - undefined, + macPasskeySigning && macEntitlementsPath + ? { + entitlementsPath: macEntitlementsPath, + provisioningProfilePath: macPasskeySigning.provisioningProfilePath, + } + : undefined, bundlesWslRuntime({ platform: options.platform, runtimeArchivePath: options.wslRuntime }), options.arch, ), diff --git a/scripts/otter/fixtures/macos-profile.plist b/scripts/otter/fixtures/macos-profile.plist new file mode 100644 index 000000000000..ab4e2ce4510d --- /dev/null +++ b/scripts/otter/fixtures/macos-profile.plist @@ -0,0 +1,20 @@ + + + + Entitlements + + com.apple.application-identifier + YNJ5WLH965.dev.otterware.code + com.apple.developer.team-identifier + YNJ5WLH965 + keychain-access-groups + YNJ5WLH965.* + com.apple.developer.associated-domains + * + aps-environment + production + com.apple.security.application-groups + group.dev.otterware.code + + + diff --git a/scripts/otter/macos-profile.test.ts b/scripts/otter/macos-profile.test.ts new file mode 100644 index 000000000000..d701daff44c0 --- /dev/null +++ b/scripts/otter/macos-profile.test.ts @@ -0,0 +1,206 @@ +// @effect-diagnostics nodeBuiltinImport:off - Tests read a synthetic profile fixture, never signing secrets. +import * as NodeFS from "node:fs"; +import Plist from "plist"; +import { assert, it } from "@effect/vitest"; + +import { + InvalidAppleTeamIdError, + InvalidMacPasskeyRpDomainError, + renderMacPasskeyEntitlements, + resolveMacPasskeySigningConfiguration, + resolveMacWebAuthnEntitlements, +} from "./macos-profile.ts"; + +const appId = "dev.otterware.code"; +const teamId = "YNJ5WLH965"; +const configuration = { appId, teamId, rpDomains: [], provisioningProfilePath: "/tmp/profile" }; +const fixture = NodeFS.readFileSync( + new URL("./fixtures/macos-profile.plist", import.meta.url), + "utf8", +); +// Like upstream's tests, include a CMS envelope around the XML payload. +const profile = (entitlements: string, outside = "") => + `0\x82\x1f\x9a\x06\t*\x86H${outside}Entitlements${entitlements}\x00\x01`; +const render = (payload: string, rpDomains: readonly string[] = []) => + Plist.parse( + renderMacPasskeyEntitlements( + { ...configuration, rpDomains }, + resolveMacWebAuthnEntitlements(payload, configuration), + payload, + ), + ) as Record; + +it("claims only the used grants from today's profile and retains hardened-runtime entitlements", () => { + assert.deepStrictEqual(render(fixture), { + "com.apple.application-identifier": `${teamId}.${appId}`, + "com.apple.developer.team-identifier": teamId, + "keychain-access-groups": [`${teamId}.${appId}.webauthn`], + "com.apple.security.cs.allow-jit": true, + "com.apple.security.cs.allow-unsigned-executable-memory": true, + "com.apple.security.cs.disable-library-validation": true, + }); + assert.deepStrictEqual(resolveMacWebAuthnEntitlements(fixture, configuration), { + touchIdKeychainAccessGroup: `${teamId}.${appId}.webauthn`, + browserPasskeys: false, + }); +}); + +it("enables the managed browser capability only after the profile grants it", () => { + const approved = fixture.replace( + "Entitlements\n ", + "Entitlements\n com.apple.developer.web-browser.public-key-credential", + ); + assert.deepStrictEqual(render(approved), { + ...render(fixture), + "com.apple.developer.web-browser.public-key-credential": true, + }); + assert.isTrue(resolveMacWebAuthnEntitlements(approved, configuration).browserPasskeys); +}); + +it("does not infer Associated Domains from Clerk keys or the Otter Accounts issuer", () => { + const resolved = resolveMacPasskeySigningConfiguration( + { + APPLE_TEAM_ID: teamId.toLowerCase(), + T3CODE_MACOS_PROVISIONING_PROFILE: " /tmp/profile ", + T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_invalid", + T3CODE_ACCOUNTS_URL: "https://accounts.otterware.app", + }, + appId, + ); + assert.deepStrictEqual(resolved, configuration); +}); + +it("preserves profile-free signing even without a team ID or domain configuration", () => { + assert.isUndefined(resolveMacPasskeySigningConfiguration({}, appId)); + assert.isUndefined( + resolveMacPasskeySigningConfiguration( + { + T3CODE_MACOS_PROVISIONING_PROFILE: " ", + T3CODE_MACOS_PASSKEY_RP_DOMAINS: "bad://domain", + }, + appId, + ), + ); +}); + +it("claims only explicitly configured Associated Domains that the profile authorizes", () => { + assert.deepStrictEqual(render(fixture, ["accounts.otterware.app"]), { + ...render(fixture), + "com.apple.developer.associated-domains": ["webcredentials:accounts.otterware.app"], + }); + const restricted = profile(`com.apple.developer.associated-domains + webcredentials:accounts.otterware.app + webcredentials:*.example.com + `); + const selected = render(restricted, [ + "accounts.otterware.app", + "login.example.com", + "example.com", + "other.com", + ]); + assert.deepPropertyVal(selected, "com.apple.developer.associated-domains", [ + "webcredentials:accounts.otterware.app", + "webcredentials:login.example.com", + ]); + assert.notProperty( + render(profile(""), ["accounts.otterware.app"]), + "com.apple.developer.associated-domains", + ); +}); + +it("normalizes and deduplicates explicit domains, and rejects unsafe values", () => { + const env = { T3CODE_APPLE_TEAM_ID: teamId, T3CODE_MACOS_PROVISIONING_PROFILE: "/tmp/profile" }; + assert.deepStrictEqual( + resolveMacPasskeySigningConfiguration( + { + ...env, + T3CODE_MACOS_PASSKEY_RP_DOMAINS: " Accounts.Otterware.app,accounts.otterware.app ", + }, + appId, + )?.rpDomains, + ["accounts.otterware.app"], + ); + for (const value of [ + "https://example.com", + "example.com:8443", + "user:password@example.com", + "example.com/path", + "example.com?token=secret", + "example.com#fragment", + "example.com,", + ]) { + assert.throws( + () => + resolveMacPasskeySigningConfiguration( + { + ...env, + T3CODE_MACOS_PASSKEY_RP_DOMAINS: value, + }, + appId, + ), + InvalidMacPasskeyRpDomainError, + ); + } + assert.throws( + () => + resolveMacPasskeySigningConfiguration( + { + ...env, + T3CODE_APPLE_TEAM_ID: "wrong", + }, + appId, + ), + InvalidAppleTeamIdError, + ); +}); + +it("ignores comments, unrelated keys, false capabilities, and unreadable profiles", () => { + const groups = "keychain-access-groupsYNJ5WLH965.*"; + const browser = "com.apple.developer.web-browser.public-key-credential"; + for (const payload of [ + "", + "unreadable", + profile(""), + profile(``), + profile( + "com.apple.developer.web-browser.public-key-credential", + groups + browser, + ), + ]) { + assert.deepStrictEqual(resolveMacWebAuthnEntitlements(payload, configuration), { + touchIdKeychainAccessGroup: undefined, + browserPasskeys: false, + }); + assert.deepStrictEqual(render(payload), { + "com.apple.security.cs.allow-jit": true, + "com.apple.security.cs.allow-unsigned-executable-memory": true, + "com.apple.security.cs.disable-library-validation": true, + }); + } +}); + +it("accepts an exact keychain grant and rejects another team's grant", () => { + assert.equal( + resolveMacWebAuthnEntitlements( + profile( + `keychain-access-groups${teamId}.${appId}.webauthn`, + ), + configuration, + ).touchIdKeychainAccessGroup, + `${teamId}.${appId}.webauthn`, + ); + assert.isUndefined( + resolveMacWebAuthnEntitlements( + profile("keychain-access-groupsOTHERTEAM1.*"), + configuration, + ).touchIdKeychainAccessGroup, + ); +}); + +it("derives the Otterware keychain group from its own app ID", () => { + const suite = { ...configuration, appId: "dev.otterware.suite" }; + assert.equal( + resolveMacWebAuthnEntitlements(fixture, suite).touchIdKeychainAccessGroup, + "YNJ5WLH965.dev.otterware.suite.webauthn", + ); +}); diff --git a/scripts/otter/macos-profile.ts b/scripts/otter/macos-profile.ts new file mode 100644 index 000000000000..9a2979f7ff53 --- /dev/null +++ b/scripts/otter/macos-profile.ts @@ -0,0 +1,247 @@ +// Ported from upstream 3e01c4bc57 and 454b94a13a. Otter's account/domain policy +// lives here so the upstream desktop packager can keep its signing flow. +// plist is CommonJS; Node cannot load its named exports from an ES module. +import Plist from "plist"; +import * as Schema from "effect/Schema"; + +const APPLE_TEAM_ID_PATTERN = /^[A-Z0-9]{10}$/u; + +export interface MacPasskeySigningConfiguration { + readonly appId: string; + readonly teamId: string; + readonly rpDomains: readonly string[]; + readonly provisioningProfilePath: string; +} + +export const InvalidMacPasskeyRpDomainReason = Schema.Literals([ + "empty", + "scheme-not-allowed", + "parse-failed", + "credentials-not-allowed", + "port-not-allowed", + "path-not-allowed", + "query-not-allowed", + "fragment-not-allowed", + "hostname-mismatch", +]); +export type InvalidMacPasskeyRpDomainReason = typeof InvalidMacPasskeyRpDomainReason.Type; + +export class InvalidMacPasskeyRpDomainError extends Schema.TaggedError()( + "InvalidMacPasskeyRpDomainError", + { + reason: InvalidMacPasskeyRpDomainReason, + inputLength: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Invalid passkey RP domain (${this.reason}).`; + } +} + +export class InvalidAppleTeamIdError extends Schema.TaggedError()( + "InvalidAppleTeamIdError", + { + teamId: Schema.String, + }, +) { + override get message(): string { + return `T3CODE_APPLE_TEAM_ID '${this.teamId}' must be a 10-character Apple Developer Team ID.`; + } +} + +function normalizePasskeyRpDomain(value: string): string { + const normalized = value.trim().toLowerCase(); + const inputLength = value.length; + if (normalized.length === 0) { + throw new InvalidMacPasskeyRpDomainError({ reason: "empty", inputLength }); + } + if (/^[a-z][a-z\d+.-]*:\/\//u.test(normalized)) { + throw new InvalidMacPasskeyRpDomainError({ + reason: "scheme-not-allowed", + inputLength, + }); + } + + let parsed: URL; + try { + parsed = new URL(`https://${normalized}`); + } catch (cause) { + throw new InvalidMacPasskeyRpDomainError({ reason: "parse-failed", inputLength, cause }); + } + + let reason: InvalidMacPasskeyRpDomainReason | undefined; + if (parsed.username.length > 0 || parsed.password.length > 0) { + reason = "credentials-not-allowed"; + } else if (parsed.port.length > 0) { + reason = "port-not-allowed"; + } else if (parsed.pathname !== "/") { + reason = "path-not-allowed"; + } else if (parsed.search.length > 0) { + reason = "query-not-allowed"; + } else if (parsed.hash.length > 0) { + reason = "fragment-not-allowed"; + } else if (parsed.host !== normalized) { + reason = "hostname-mismatch"; + } + if (reason) { + throw new InvalidMacPasskeyRpDomainError({ reason, inputLength }); + } + + return parsed.hostname; +} + +export function resolveMacPasskeySigningConfiguration( + env: Readonly>, + appId: string, +): MacPasskeySigningConfiguration | undefined { + const provisioningProfilePath = env.T3CODE_MACOS_PROVISIONING_PROFILE?.trim() ?? ""; + // A profile is optional: keep existing Developer ID signing/notarization. + if (provisioningProfilePath.length === 0) return undefined; + + const teamId = (env.T3CODE_APPLE_TEAM_ID ?? env.APPLE_TEAM_ID)?.trim().toUpperCase() ?? ""; + if (!APPLE_TEAM_ID_PATTERN.test(teamId)) { + throw new InvalidAppleTeamIdError({ teamId }); + } + + // Otter Accounts uses no Clerk. Only explicitly configured domains are claimed. + const configuredRpDomains = env.T3CODE_MACOS_PASSKEY_RP_DOMAINS?.trim(); + const rpDomains = configuredRpDomains + ? configuredRpDomains.split(",").map(normalizePasskeyRpDomain) + : []; + return { appId, teamId, rpDomains: [...new Set(rpDomains)], provisioningProfilePath }; +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +/** + * Passkey entitlements for the in-app browser. Each is granted only when the + * provisioning profile authorizes it: macOS refuses to launch an app that + * claims a restricted entitlement its embedded profile does not carry. + */ +export interface MacWebAuthnEntitlements { + /** Keychain group for Electron's Touch ID passkeys. */ + readonly touchIdKeychainAccessGroup: string | undefined; + /** Apple's managed browser entitlement, which allows passkeys for any site. */ + readonly browserPasskeys: boolean; +} + +const BROWSER_PASSKEYS_ENTITLEMENT = "com.apple.developer.web-browser.public-key-credential"; + +const ProvisioningProfilePlist = Schema.Struct({ + Entitlements: Schema.Struct({ + "com.apple.application-identifier": Schema.optional(Schema.String), + "com.apple.developer.team-identifier": Schema.optional(Schema.String), + "com.apple.developer.associated-domains": Schema.optional(Schema.Array(Schema.String)), + "keychain-access-groups": Schema.optional(Schema.Array(Schema.String)), + [BROWSER_PASSKEYS_ENTITLEMENT]: Schema.optional(Schema.Boolean), + }), +}); +const isProvisioningProfilePlist = Schema.is(ProvisioningProfilePlist); + +/** + * Reads the Entitlements dict of the XML plist a provisioning profile wraps in + * its CMS envelope. Anything unreadable grants nothing. + */ +const readProfileEntitlements = (provisioningProfile: string) => { + const start = provisioningProfile.indexOf("", start); + if (start === -1 || end === -1) return undefined; + try { + const profile: unknown = Plist.parse(provisioningProfile.slice(start, end + "".length)); + return isProvisioningProfilePlist(profile) ? profile.Entitlements : undefined; + } catch { + return undefined; + } +}; + +export function resolveMacWebAuthnEntitlements( + provisioningProfile: string, + configuration: Pick, +): MacWebAuthnEntitlements { + const entitlements = readProfileEntitlements(provisioningProfile); + const keychainAccessGroup = `${configuration.teamId}.${configuration.appId}.webauthn`; + const keychainGroupAuthorized = (entitlements?.["keychain-access-groups"] ?? []).some((group) => + group.endsWith("*") + ? keychainAccessGroup.startsWith(group.slice(0, -1)) + : group === keychainAccessGroup, + ); + return { + touchIdKeychainAccessGroup: keychainGroupAuthorized ? keychainAccessGroup : undefined, + browserPasskeys: entitlements?.[BROWSER_PASSKEYS_ENTITLEMENT] === true, + }; +} + +export function renderMacPasskeyEntitlements( + configuration: MacPasskeySigningConfiguration, + webAuthn: MacWebAuthnEntitlements, + provisioningProfile: string, +): string { + const grants = readProfileEntitlements(provisioningProfile); + const appIdentifier = `${configuration.teamId}.${configuration.appId}`; + const applicationIdentifier = + grants?.["com.apple.application-identifier"] === appIdentifier + ? ` + com.apple.application-identifier + ${escapeXml(appIdentifier)}` + : ""; + const teamIdentifier = + grants?.["com.apple.developer.team-identifier"] === configuration.teamId + ? ` + com.apple.developer.team-identifier + ${escapeXml(configuration.teamId)}` + : ""; + const associatedDomains = configuration.rpDomains + .filter((domain) => + (grants?.["com.apple.developer.associated-domains"] ?? []).some( + (grant) => + grant === "*" || + grant === `webcredentials:${domain}` || + (grant.startsWith("webcredentials:*.") && + domain.endsWith(grant.slice("webcredentials:*".length))), + ), + ) + .map((domain) => ` webcredentials:${escapeXml(domain)}`) + .join("\n"); + const associatedDomainsEntitlement = + associatedDomains.length > 0 + ? ` + com.apple.developer.associated-domains + +${associatedDomains} + ` + : ""; + const keychainAccessGroups = webAuthn.touchIdKeychainAccessGroup + ? ` + keychain-access-groups + + ${escapeXml(webAuthn.touchIdKeychainAccessGroup)} + ` + : ""; + const browserPasskeys = webAuthn.browserPasskeys + ? ` + ${BROWSER_PASSKEYS_ENTITLEMENT} + ` + : ""; + + return ` + + + ${applicationIdentifier}${teamIdentifier}${associatedDomainsEntitlement}${keychainAccessGroups}${browserPasskeys} + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + + +`; +} diff --git a/scripts/package.json b/scripts/package.json index 8057944b129e..23a3c194cecd 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -13,11 +13,13 @@ "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", + "plist": "catalog:", "pngjs": "7.0.0", "sharp": "0.35.4" }, "devDependencies": { "@effect/vitest": "catalog:", + "@types/plist": "catalog:", "@types/pngjs": "6.0.5", "typescript": "catalog:", "typescript-legacy": "npm:typescript@~6.0.3",