diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 6ec5c33..57a5992 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -77,16 +77,16 @@ jobs: APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail missing=() - for var in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_ID APPLE_PASSWORD APPLE_TEAM_ID KEYCHAIN_PASSWORD TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do + for var in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APP_STORE_CONNECT_KEY_ID APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_PRIVATE_KEY KEYCHAIN_PASSWORD TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD; do if [[ -z "${!var:-}" ]]; then missing+=("$var") fi @@ -133,7 +133,6 @@ jobs: - name: Build universal Tauri app (.app only — DMG is built later from embedded version) env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} STINT_GOOGLE_CLIENT_ID: ${{ secrets.STINT_GOOGLE_CLIENT_ID }} STINT_GOOGLE_CLIENT_SECRET: ${{ secrets.STINT_GOOGLE_CLIENT_SECRET }} run: | @@ -202,9 +201,9 @@ jobs: - name: Notarize .app env: - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} run: scripts/release/notarize.sh "$APP_PATH" - name: Build .dmg @@ -224,9 +223,9 @@ jobs: - name: Sign + notarize .dmg env: APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + APP_STORE_CONNECT_PRIVATE_KEY: ${{ secrets.APP_STORE_CONNECT_PRIVATE_KEY }} run: | codesign --force --sign "$APPLE_SIGNING_IDENTITY" "$DMG_PATH" scripts/release/notarize.sh "$DMG_PATH" diff --git a/docs/runbooks/release-key-rotation.md b/docs/runbooks/release-key-rotation.md index 1c94aae..9126ec8 100644 --- a/docs/runbooks/release-key-rotation.md +++ b/docs/runbooks/release-key-rotation.md @@ -1,68 +1,109 @@ # Runbook — Release credential rotation -Procedures for rotating each of the credentials Phase 4 introduced. -None of these are scheduled tasks — they're triggered by external events -(annual cert expiry, suspected compromise, Apple-mandated password reset). +The release pipeline holds four rotatable credentials. All four are rotated +via `scripts/release/rotate-key.sh `, which walks any +unavoidable manual steps (App Store Connect / Xcode UI) with explicit +checkpoints, then handles the GitHub-secret upload and verification. -## 1. APPLE_PASSWORD (app-specific password) +This document is the why-and-when reference; the script is the how. -**Trigger:** Apple invalidates the password (typically annually, or on -account-security events). +| Credential | Trigger | Cadence | Subcommand | +|---|---|---|---| +| App Store Connect API key | suspected compromise, hygiene rotation | flexible | `rotate-key.sh app-store-connect-key` | +| Developer ID Application cert | expiry, revocation | 5 years | `rotate-key.sh apple-cert` | +| Tauri updater signing key | suspected compromise ONLY | never proactively | `rotate-key.sh tauri-key` | -**Procedure:** +Plus one auto-managed credential (`KEYCHAIN_PASSWORD`) that doesn't rotate +manually — `bootstrap-secrets.sh` regenerates it whenever called. -1. Visit appleid.apple.com → Sign-In and Security → App-Specific Passwords. -2. Revoke the old "stint notary" password. -3. Generate a new one. Apple shows it once. -4. `scripts/release/bootstrap-secrets.sh APPLE_PASSWORD` -5. Trigger a smoke release to verify (or wait for the next legitimate - `feat:`/`fix:` push). +## 1. App Store Connect API key -**Blast radius:** Releases fail at the notarization step with an -authentication error until the new password is in place. +Authenticates `xcrun notarytool` against Apple's notary service. Used on +every release. -## 2. APPLE_CERTIFICATE (Developer ID Application) +**Why it exists:** stint previously used `APPLE_ID` + `APPLE_PASSWORD` +(app-specific password) + `APPLE_TEAM_ID`. App-specific passwords expire +annually and can only be generated via the appleid.apple.com web UI — no +API. Migrating to App Store Connect API key auth eliminates that recurring +manual chore: the key is created once via the App Store Connect web UI and +afterwards every rotation is scripted. -**Trigger:** Annual expiry, or cert revocation. +**Blast radius:** releases fail at notarization until the new key is in +place. Existing shipped releases unaffected (the key authenticates +submission, not the resulting signature). -**Procedure:** +**Rotation:** -1. In Xcode → Settings → Accounts → Manage Certificates → "+" → "Developer ID - Application". -2. The new cert appears in your login keychain alongside the old one. -3. Export both cert + private key as `.p12` (same procedure as Task 1.1). -4. Record the new "Common Name" — likely identical to the old one with a - different expiry date. -5. `scripts/release/bootstrap-secrets.sh APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY` -6. Optionally delete the old cert from keychain after the next successful - release. +```bash +scripts/release/rotate-key.sh app-store-connect-key +``` -**Blast radius:** Releases fail at the codesign step until the new cert is -in place. Existing signed-and-shipped releases continue to work — Gatekeeper -trusts the cert's signature, not the cert's current validity. +The script walks you through creating a new key in App Store Connect, +downloading the `.p8`, then handles upload of all three secrets +(`APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`, +`APP_STORE_CONNECT_PRIVATE_KEY`). At the end it prompts you to revoke the +old key in App Store Connect so a leaked `.p8` is no longer usable. -## 3. TAURI_SIGNING_PRIVATE_KEY (updater key) +## 2. Developer ID Application cert -**Trigger:** Only suspected compromise. Never rotate proactively. +Codesigns the `.app`, `.dmg`, and embedded CLI binary. Used on every +release. -**Procedure:** +**Trigger:** Apple's Developer ID certs are valid for 5 years from +issuance. Watch for expiry warnings in build logs roughly 90 days out. -This is the dangerous one. Rotating breaks auto-update for every existing -install of stint. +**Blast radius:** releases fail at codesign until the new cert is in +place. Existing shipped releases continue to work — Gatekeeper trusts the +cert's signature at the time of signing, not its current validity. -1. Generate a new key: `cargo tauri signer generate -w ~/.tauri/stint-new.key`. -2. Update `crates/stint-app/tauri.conf.json` `plugins.updater.pubkey` with - the new public key. -3. `scripts/release/bootstrap-secrets.sh TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD` -4. Cut a release containing **only** the pubkey change (it gets a `fix:` - commit, triggers a patch release). -5. Existing installs run the old build, which verifies updates against the - *old* public key. The new release's `latest.json` is signed with the new - key — existing installs reject the update. -6. Push a notice via the docs site instructing users to reinstall manually - (`brew reinstall --cask stint` or rerun the install script). -7. Future releases follow the normal path; only the gap-installs (users who - manually reinstall) bridge to the new key. +**Rotation:** -**Blast radius:** Total auto-update outage until users manually reinstall. -Last resort. +```bash +scripts/release/rotate-key.sh apple-cert +``` + +The script walks you through generating a new cert in Xcode → Keychain +Access, exporting to `.p12`, then handles upload of all three secrets +(`APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_SIGNING_IDENTITY`). + +Cert generation can't be fully scripted — Apple gates `.p12` export of +private keys through Keychain Access. (Generating certs via App Store +Connect API is possible but the JWT + CSR + key-pair-stitching machinery +isn't worth writing for a 5-year cadence.) + +## 3. Tauri updater signing key + +Signs `latest.json` so `tauri-plugin-updater` in existing installs can +verify update payloads. Every shipped stint binary embeds the *public* +half; rotating the *private* half means existing binaries reject all +future updates as signature-mismatched. + +**Trigger:** suspected compromise ONLY. Never rotate proactively. + +**Blast radius:** total auto-update outage until users manually reinstall. +The runbook for that recovery — pinning the cask, pushing a +`recovery.html`, surfacing a notice in the README — is at +`docs/runbooks/release-rollback.md`. + +**Rotation:** + +```bash +scripts/release/rotate-key.sh tauri-key +``` + +The script requires typing `I understand auto-update will break` literally +before proceeding. It generates a new keypair, edits +`crates/stint-app/tauri.conf.json` with the new pubkey, uploads the new +private key, and reminds you to commit + ship the pubkey change. + +## When the script can't help + +If `gh auth status` fails, fix authentication before running rotation: +the script aborts up front rather than getting halfway through. + +If `bootstrap-secrets.sh` doesn't exist or isn't executable, the script +aborts. (`chmod +x scripts/release/bootstrap-secrets.sh` if needed.) + +For first-time setup (no existing secrets), run `bootstrap-secrets.sh` +directly with no arguments — it walks every secret in sequence. +`rotate-key.sh` only handles re-credentialing of already-set secrets. diff --git a/docs/superpowers/specs/2026-05-21-stint-phase-4-distribution-design.md b/docs/superpowers/specs/2026-05-21-stint-phase-4-distribution-design.md index 9b4a5a2..17ae135 100644 --- a/docs/superpowers/specs/2026-05-21-stint-phase-4-distribution-design.md +++ b/docs/superpowers/specs/2026-05-21-stint-phase-4-distribution-design.md @@ -129,17 +129,26 @@ The Tauri updater key is **never rotated** unless compromise is suspected. Rotat APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY # "Developer ID Application: Mario Meyer (TEAMID)" -APPLE_ID -APPLE_PASSWORD -APPLE_TEAM_ID +APP_STORE_CONNECT_KEY_ID # 10-char alphanumeric, from App Store Connect API keys page +APP_STORE_CONNECT_ISSUER_ID # UUID, same value for every key under the team +APP_STORE_CONNECT_PRIVATE_KEY # base64 of the .p8 file KEYCHAIN_PASSWORD # arbitrary; for ephemeral keychain unlock TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD HOMEBREW_TAP_TOKEN # fine-grained PAT, scoped read+write to reyemtech/homebrew-tap only +RELEASE_TOKEN # fine-grained PAT for semrelease push-back to main (bypass actor required) STINT_GOOGLE_CLIENT_ID # reuses the same client as dev .env.local (deliberate; see §11) STINT_GOOGLE_CLIENT_SECRET ``` +`xcrun notarytool` is authenticated via App Store Connect API key (the +`--key`/`--key-id`/`--issuer` flags) rather than the legacy +`--apple-id`/`--password`/`--team-id` form. The API key is generated once +via the App Store Connect web UI; subsequent rotations are fully scripted +via `rotate-key.sh app-store-connect-key`. App-specific passwords (which +expire annually and can only be regenerated via Apple's web UI) are no +longer in the pipeline. + ### Ephemeral keychain in CI CI creates a fresh keychain per run, imports the cert, signs, and deletes the keychain on cleanup (even on failure). Never touches the runner's default keychain. Sign with `--options runtime` for hardened-runtime compliance. @@ -169,30 +178,59 @@ The three `cs.*` entitlements are required by WebView2/wry under hardened runtim ### Bootstrap script -Setting up twelve secrets by hand is error-prone — the most likely first-release failure mode is "I typo'd a base64-encoded cert." Phase 4 includes `scripts/release/bootstrap-secrets.sh`, an interactive walkthrough that: +Setting up the secret inventory by hand is error-prone — the most likely +first-release failure mode is "I typo'd a base64-encoded cert." Phase 4 +includes `scripts/release/bootstrap-secrets.sh`, an interactive walkthrough +that: 1. Verifies `gh` CLI is authenticated and has write access to the repo. -2. Generates the Tauri updater key pair via `tauri signer generate` (interactive prompt for passphrase), prints the public key for manual paste into `tauri.conf.json`, and pushes the private key + passphrase to GitHub secrets. -3. For each Apple secret, walks the user through the manual step (e.g., "open Keychain Access, find your Developer ID Application cert, export as .p12"), waits for the resulting file, base64-encodes, and pushes to GitHub. -4. Detects existing secrets and prompts before overwriting (idempotent re-runs are safe). +2. Generates the Tauri updater key pair via `tauri signer generate` + (interactive passphrase prompt), substitutes the new public key into + `tauri.conf.json`, and pushes the private key + passphrase to GitHub. +3. For each Apple secret, walks the user through the manual step (Keychain + Access export, App Store Connect API key download), waits for the + resulting file, base64-encodes, and pushes to GitHub. +4. Detects existing secrets and prompts before overwriting (idempotent + re-runs are safe). 5. Verifies each secret was set successfully via `gh secret list`. -6. Prints a final checklist of manual one-time steps that can't be scripted (DNS record for `stint.reyem.tech`, creating the empty `reyemtech/homebrew-tap` repo, registering Apple Notary credentials at appleid.apple.com). - -Not scripted because they're too security-sensitive to automate: - -- Generating the Apple Developer ID Application cert (must happen in Xcode → Settings → Accounts → Manage Certificates with explicit user action). -- Creating the app-specific password at appleid.apple.com (Apple does not expose an API for this). -- Reviewing the public key against the committed `tauri.conf.json` before merging the first release. - -The script is run once per maintainer setup, not per release. It's also the recovery tool when rotating any of the credentials (re-run with the specific secret name as an arg: `./bootstrap-secrets.sh APPLE_PASSWORD`). +6. Prints a final checklist of one-time manual steps that can't be + scripted (DNS record for `stint.reyem.tech`, creating the empty + `reyemtech/homebrew-tap` repo). + +Not scripted because they're too security-sensitive to automate or have no +API: + +- Generating the Apple Developer ID Application cert (Xcode → Settings → + Accounts → Manage Certificates with explicit user action). +- Creating the App Store Connect API key (Apple gates key creation behind + the App Store Connect web UI; no API exists). The script does handle + uploading the resulting `.p8`. +- Reviewing the new Tauri public key against the committed + `tauri.conf.json` before merging the first release. + +The script runs once per maintainer setup. For ongoing rotation, use +`rotate-key.sh` (see "Key rotation runbook" below), which wraps +`bootstrap-secrets.sh` with guided pre-rotation walkthroughs and +post-rotation verification. ### Key rotation runbook -Lives at `docs/runbooks/release-key-rotation.md`. Covers: - -- **`APPLE_PASSWORD` (annual)** — generate new app-specific password at appleid.apple.com, swap secret. -- **`APPLE_CERTIFICATE` (annual)** — generate new Developer ID cert in Xcode → Keychain Access, export as `.p12`, base64-encode, swap secret + `APPLE_SIGNING_IDENTITY`. -- **`TAURI_SIGNING_PRIVATE_KEY` (only if compromised)** — generate new key, ship a brew-only release telling users to reinstall manually, then rotate. Public key in `tauri.conf.json` changes; existing installs lose auto-update until manual reinstall. +Lives at `docs/runbooks/release-key-rotation.md`. Each rotation is driven by +`scripts/release/rotate-key.sh `, which walks unavoidable +manual steps with explicit checkpoints, delegates upload to +`bootstrap-secrets.sh`, and verifies the secret's `updatedAt` timestamp +changed. + +- **App Store Connect API key (`app-store-connect-key`)** — rotates the + three `APP_STORE_CONNECT_*` secrets. Creating the key is web-UI-only + (Apple exposes no API for that); everything else is scripted. +- **Apple cert (`apple-cert`)** — rotates the three Apple cert secrets. + Cert generation happens in Xcode → Keychain Access; export + upload + + verify are scripted. +- **Tauri key (`tauri-key`)** — rotates the updater signing key. Requires + a literal "I understand auto-update will break" confirmation. Generates + the new keypair, patches `tauri.conf.json`, uploads the secret. Existing + installs lose auto-update until manual reinstall. ## 5. Homebrew cask + tap repo @@ -608,8 +646,9 @@ pnpm-lock.yaml # already exists at root; semrelease d .releaserc.json # semantic-release config scripts/release/ bootstrap-secrets.sh # interactive walkthrough for §4 secret setup + rotate-key.sh # guided rotation wrapper around bootstrap-secrets.sh bump-versions.sh - notarize.sh + notarize.sh # App Store Connect API key auth generate-latest-json.sh render-install-script.sh update-cask.sh diff --git a/scripts/release/bootstrap-secrets.sh b/scripts/release/bootstrap-secrets.sh index 976637a..b7356f2 100755 --- a/scripts/release/bootstrap-secrets.sh +++ b/scripts/release/bootstrap-secrets.sh @@ -21,9 +21,9 @@ readonly SECRETS=( APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY - APPLE_ID - APPLE_PASSWORD - APPLE_TEAM_ID + APP_STORE_CONNECT_KEY_ID + APP_STORE_CONNECT_ISSUER_ID + APP_STORE_CONNECT_PRIVATE_KEY KEYCHAIN_PASSWORD TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD @@ -102,27 +102,55 @@ TIP set_secret APPLE_SIGNING_IDENTITY "$identity" } -prompt_apple_id() { - read -r -p "Apple ID email: " email - set_secret APPLE_ID "$email" +prompt_app_store_connect_key_id() { + cat <<'TIP' +Create at https://appstoreconnect.apple.com/access/api + - Name: stint-ci + - Access: Developer (sufficient for notarization) +The Key ID appears in the table after creation (10 uppercase alphanumerics). +TIP + read -r -p "Key ID: " kid + [[ "$kid" =~ ^[A-Z0-9]{10}$ ]] || { + echo "error: must be 10 uppercase alphanumerics" >&2 + return 1 + } + set_secret APP_STORE_CONNECT_KEY_ID "$kid" } -prompt_apple_password() { +prompt_app_store_connect_issuer_id() { cat <<'TIP' -Create an app-specific password at appleid.apple.com → Sign-In and Security -→ App-Specific Passwords. Apple shows it once; do not lose it. +Issuer ID is shown at the top of the App Store Connect API keys page — +a UUID like 12345678-1234-1234-1234-1234567890ab. Same value for every key +under your team account. TIP - read -r -s -p "App-specific password: " pwd; echo - set_secret APPLE_PASSWORD "$pwd" + read -r -p "Issuer ID (UUID): " iid + [[ "$iid" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || { + echo "error: must be a UUID (8-4-4-4-12 hex)" >&2 + return 1 + } + set_secret APP_STORE_CONNECT_ISSUER_ID "$iid" } -prompt_apple_team_id() { - read -r -p "Apple Team ID (10 chars): " team - [[ "$team" =~ ^[A-Z0-9]{10}$ ]] || { - echo "error: must be 10 uppercase alphanumerics" >&2 +prompt_app_store_connect_private_key() { + cat <<'TIP' +After creating the key, click "Download API Key" — Apple offers the .p8 +exactly once. Pass that file's path here. Stored as base64 so GitHub +secrets handle the PEM cleanly. +TIP + read -r -e -p "Path to AuthKey_*.p8: " p8_path + [[ -f "$p8_path" ]] || { echo "error: file not found" >&2; return 1; } + grep -q "BEGIN PRIVATE KEY" "$p8_path" || { + echo "error: $p8_path does not look like a PEM private key" >&2 return 1 } - set_secret APPLE_TEAM_ID "$team" + set_secret APP_STORE_CONNECT_PRIVATE_KEY "$(base64 < "$p8_path")" + read -r -p "Securely delete $p8_path now? [y/N] " resp + if [[ "$resp" =~ ^[Yy]$ ]]; then + rm -P "$p8_path" 2>/dev/null || rm -f "$p8_path" + echo "✓ removed $p8_path" + else + echo " reminder: $p8_path contains private key material; delete manually when done" + fi } prompt_keychain_password() { diff --git a/scripts/release/notarize.sh b/scripts/release/notarize.sh index 87c4bbb..2828ebd 100755 --- a/scripts/release/notarize.sh +++ b/scripts/release/notarize.sh @@ -6,16 +6,30 @@ # # Usage: notarize.sh # -# Required env: APPLE_ID, APPLE_PASSWORD, APPLE_TEAM_ID +# Required env: +# APP_STORE_CONNECT_KEY_ID — 10-char alphanumeric key ID +# APP_STORE_CONNECT_ISSUER_ID — UUID of the App Store Connect team +# APP_STORE_CONNECT_PRIVATE_KEY — base64-encoded contents of the .p8 file +# +# Authenticates via App Store Connect API key (rather than the legacy +# app-specific-password mode) so credentials can be rotated without humans +# generating new passwords at appleid.apple.com. set -euo pipefail readonly ARTIFACT="${1:?signed artifact path required}" readonly MAX_ATTEMPTS=3 -: "${APPLE_ID:?must be set}" -: "${APPLE_PASSWORD:?must be set}" -: "${APPLE_TEAM_ID:?must be set}" +: "${APP_STORE_CONNECT_KEY_ID:?must be set}" +: "${APP_STORE_CONNECT_ISSUER_ID:?must be set}" +: "${APP_STORE_CONNECT_PRIVATE_KEY:?must be set}" + +# Materialize the .p8 to a temp file — notarytool only accepts a path, not a +# string. Clean up on any exit. +KEY_FILE="$(mktemp -t notary-key.XXXXXX.p8)" +chmod 600 "$KEY_FILE" +echo "$APP_STORE_CONNECT_PRIVATE_KEY" | base64 -d > "$KEY_FILE" +trap 'rm -f "$KEY_FILE"' EXIT # notarytool wants a zip for .app submissions. SUBMIT_PATH="$ARTIFACT" @@ -30,9 +44,9 @@ for attempt in $(seq 1 $MAX_ATTEMPTS); do echo "→ notarize attempt $attempt/$MAX_ATTEMPTS" set +e output=$(xcrun notarytool submit "$SUBMIT_PATH" \ - --apple-id "$APPLE_ID" \ - --password "$APPLE_PASSWORD" \ - --team-id "$APPLE_TEAM_ID" \ + --key "$KEY_FILE" \ + --key-id "$APP_STORE_CONNECT_KEY_ID" \ + --issuer "$APP_STORE_CONNECT_ISSUER_ID" \ --wait \ --output-format json 2>&1) rc=$? @@ -68,7 +82,9 @@ PY echo "error: notarization status: $status" >&2 if [[ -n "$submission_id" ]]; then xcrun notarytool log "$submission_id" \ - --apple-id "$APPLE_ID" --password "$APPLE_PASSWORD" --team-id "$APPLE_TEAM_ID" + --key "$KEY_FILE" \ + --key-id "$APP_STORE_CONNECT_KEY_ID" \ + --issuer "$APP_STORE_CONNECT_ISSUER_ID" fi exit 1 fi diff --git a/scripts/release/rotate-key.sh b/scripts/release/rotate-key.sh new file mode 100755 index 0000000..e981328 --- /dev/null +++ b/scripts/release/rotate-key.sh @@ -0,0 +1,306 @@ +#!/usr/bin/env bash +# scripts/release/rotate-key.sh +# Guided credential rotation for the release pipeline. +# +# Each subcommand walks the human through any unavoidable manual steps +# (App Store Connect / Xcode UI) with explicit checkpoints, then delegates +# the actual GitHub-secret upload to bootstrap-secrets.sh, then verifies the +# secret's updatedAt timestamp changed and optionally triggers a smoke +# release. +# +# Subcommands: +# app-store-connect-key Rotate the App Store Connect API key used for +# notarization. Creating an API key is web-UI-only +# (Apple exposes no API for that); everything else +# is scripted. +# apple-cert Rotate the Developer ID Application cert +# (5-year cadence). Xcode generates the cert; the +# script handles export, upload, and verify. +# tauri-key Rotate the Tauri updater signing key. +# DANGEROUS — breaks auto-update for every +# existing install until they manually reinstall. +# Use only on suspected compromise. + +set -euo pipefail + +readonly REPO="reyemtech/stint" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly BOOTSTRAP="$SCRIPT_DIR/bootstrap-secrets.sh" + +# ── helpers ─────────────────────────────────────────────────────────────────── + +bold() { printf '\033[1m%s\033[0m\n' "$*"; } +step() { printf '\n\033[36m── %s ──\033[0m %s\n' "$1" "$2"; } +warn() { printf '\033[33m! %s\033[0m\n' "$*" >&2; } +ok() { printf '\033[32m✓ %s\033[0m\n' "$*"; } +abort() { printf '\033[31m✗ %s\033[0m\n' "$*" >&2; exit 1; } + +require_tools() { + command -v gh >/dev/null || abort "gh not installed. brew install gh" + gh auth status >/dev/null 2>&1 || abort "gh not authenticated. gh auth login" + [[ -x "$BOOTSTRAP" ]] || abort "$BOOTSTRAP not executable" +} + +pause() { + local msg="${1:-Press ENTER to continue, Ctrl-C to abort}" + read -r -p "$msg: " _ +} + +secret_updated_at() { + gh secret list --repo "$REPO" --json name,updatedAt \ + -q ".[] | select(.name==\"$1\") | .updatedAt" 2>/dev/null || echo "" +} + +verify_secret_rotated() { + local name="$1" before="$2" + local after + after=$(secret_updated_at "$name") + if [[ -z "$after" ]]; then + abort "$name not found on $REPO after rotation" + fi + if [[ "$after" == "$before" ]]; then + warn "$name updatedAt did not change — secret may not have been re-set" + return 1 + fi + ok "$name updated on $REPO ($after)" +} + +offer_smoke_release() { + cat </dev/null \ + || echo " https://github.com/$REPO/actions/workflows/release.yml" + fi +} + +# ── app-store-connect-key ───────────────────────────────────────────────────── + +cmd_app_store_connect_key() { + bold "Rotate App Store Connect API key (notarization auth)" + cat <<'EOF' + +Rotates APP_STORE_CONNECT_KEY_ID + APP_STORE_CONNECT_ISSUER_ID + +APP_STORE_CONNECT_PRIVATE_KEY in one pass. Used for `xcrun notarytool +--key` authentication. + +Trigger: suspected compromise, or "cycling for hygiene every N months." +Blast radius: releases fail at notarization step until the new key is + in place. Existing shipped releases unaffected (the key + authenticates submission, not the eventual signature). + +EOF + step 1 "Create a new API key in App Store Connect" + cat <<'EOF' + 1. Open https://appstoreconnect.apple.com/access/api + 2. Click the "+" next to "Active" (or "Keys" header) + 3. Name: stint-ci-YYYY-MM (or similar — helps identify in rotation history) + 4. Access: Developer (the minimum role for notarytool to work) + 5. Click "Generate" + 6. Click "Download API Key" — Apple offers the .p8 file exactly once. + Save it somewhere temporary, e.g. ~/Downloads/AuthKey_XXXXXXXXXX.p8 + 7. Note the Key ID (10-char alphanumeric) in the table. + 8. Note the Issuer ID (UUID) at the top of the keys page. + +After bootstrap-secrets.sh finishes, REVOKE the previous key on the same +page so an exposed old .p8 stops being usable. The new key remains the +sole active credential. +EOF + pause "Press ENTER once the .p8 is downloaded and you've noted Key ID + Issuer ID" + + local before_kid before_iid before_key + before_kid=$(secret_updated_at APP_STORE_CONNECT_KEY_ID) + before_iid=$(secret_updated_at APP_STORE_CONNECT_ISSUER_ID) + before_key=$(secret_updated_at APP_STORE_CONNECT_PRIVATE_KEY) + + step 2 "Upload to GitHub secrets" + "$BOOTSTRAP" APP_STORE_CONNECT_KEY_ID APP_STORE_CONNECT_ISSUER_ID APP_STORE_CONNECT_PRIVATE_KEY + + step 3 "Verify rotation" + verify_secret_rotated APP_STORE_CONNECT_KEY_ID "$before_kid" || true + verify_secret_rotated APP_STORE_CONNECT_ISSUER_ID "$before_iid" || true + verify_secret_rotated APP_STORE_CONNECT_PRIVATE_KEY "$before_key" || true + + step 4 "Revoke the old key in App Store Connect" + cat <<'EOF' + Back at https://appstoreconnect.apple.com/access/api, click the old key + in the Active table → "Revoke Key" → confirm. Revoked keys cannot be + un-revoked, but if you skip this step and the old .p8 leaks, anyone + with it can notarize binaries under your team's name until expiry. +EOF + pause "Press ENTER when the old key is revoked" + + step 5 "Smoke test" + offer_smoke_release +} + +# ── apple-cert ──────────────────────────────────────────────────────────────── + +cmd_apple_cert() { + bold "Rotate Developer ID Application cert" + cat <<'EOF' + +Rotates APPLE_CERTIFICATE + APPLE_CERTIFICATE_PASSWORD + +APPLE_SIGNING_IDENTITY in one pass. Used for codesigning the .app + .dmg ++ embedded CLI. + +Trigger: annual / 5-year expiry, or revocation. +Blast radius: releases fail at codesign until the new cert is in place. + Existing shipped releases continue to work — Gatekeeper + trusts the cert's signature, not its current validity. + +EOF + step 1 "Generate a new cert in Xcode" + cat <<'EOF' + 1. Open Xcode + 2. Settings (⌘,) → Accounts + 3. Select your Apple Developer account + 4. Click "Manage Certificates…" + 5. Click "+" → "Developer ID Application" + 6. The new cert appears in your login Keychain alongside the old one. +EOF + pause "Press ENTER when the new cert is in Keychain" + + step 2 "Export cert + private key to .p12" + cat <<'EOF' + 1. Open Keychain Access + 2. Find the new "Developer ID Application: …" cert + 3. Right-click → Export… + 4. File Format: Personal Information Exchange (.p12) + 5. Save to e.g. /tmp/dev-id-new.p12 + 6. Choose a strong export password — bootstrap-secrets.sh will ask for it. +EOF + pause "Press ENTER when the .p12 is exported" + + step 3 "Note the new Common Name" + cat <<'EOF' + In Keychain Access, double-click the new cert. The "Common Name" field + reads: Developer ID Application: Your Name (TEAMID) + Copy it — bootstrap-secrets.sh will ask for it. +EOF + + local before_cert before_pwd before_id + before_cert=$(secret_updated_at APPLE_CERTIFICATE) + before_pwd=$(secret_updated_at APPLE_CERTIFICATE_PASSWORD) + before_id=$(secret_updated_at APPLE_SIGNING_IDENTITY) + + step 4 "Upload all three secrets" + "$BOOTSTRAP" APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY + + step 5 "Verify rotation" + verify_secret_rotated APPLE_CERTIFICATE "$before_cert" || true + verify_secret_rotated APPLE_CERTIFICATE_PASSWORD "$before_pwd" || true + verify_secret_rotated APPLE_SIGNING_IDENTITY "$before_id" || true + + step 6 "Optional: delete the old cert from Keychain" + cat <<'EOF' + In Keychain Access, you can delete the old "Developer ID Application" + cert after the next successful release confirms the new one works. +EOF + + step 7 "Smoke test" + offer_smoke_release +} + +# ── tauri-key ───────────────────────────────────────────────────────────────── + +cmd_tauri_key() { + bold "Rotate TAURI_SIGNING_PRIVATE_KEY (updater signing key)" + cat <<'EOF' + +⚠️ DANGER ⚠️ + +This rotation BREAKS auto-update for EVERY existing install of stint. +Existing installs verify update manifests against the OLD public key +baked into their binary; the new release's manifest is signed with the +NEW key, which existing installs will REJECT. + +Recovery requires every user to manually reinstall: + brew reinstall --cask stint + # or + curl -fsSL https://stint.reyem.tech/install.sh | sh + +ONLY rotate this key if you have credible reason to believe the current +key has been compromised. Never rotate proactively. + +EOF + read -r -p "Type 'I understand auto-update will break' to proceed: " confirm + if [[ "$confirm" != "I understand auto-update will break" ]]; then + abort "rotation aborted" + fi + + local before_key before_pwd + before_key=$(secret_updated_at TAURI_SIGNING_PRIVATE_KEY) + before_pwd=$(secret_updated_at TAURI_SIGNING_PRIVATE_KEY_PASSWORD) + + step 1 "Generate new key + patch tauri.conf.json" + warn "bootstrap-secrets.sh will overwrite ~/.tauri/stint.key and edit crates/stint-app/tauri.conf.json" + "$BOOTSTRAP" TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD + + step 2 "Verify secrets rotated" + verify_secret_rotated TAURI_SIGNING_PRIVATE_KEY "$before_key" || true + verify_secret_rotated TAURI_SIGNING_PRIVATE_KEY_PASSWORD "$before_pwd" || true + + step 3 "Commit + ship the pubkey change" + cat <<'EOF' + bootstrap-secrets.sh edited crates/stint-app/tauri.conf.json in place + with the new public key. Commit + push so the next release embeds it: + + git status crates/stint-app/tauri.conf.json + git add crates/stint-app/tauri.conf.json + git commit -m "fix(updater): rotate signing key (incident response)" + git push + + CI cuts a release with the new pubkey embedded. Existing installs run + the OLD binary with the OLD pubkey — they will reject this new release. + +EOF + + step 4 "Notify users" + cat <<'EOF' + After the recovery-key release ships: + 1. Push a notice to https://stint.reyem.tech/recovery.html with + reinstall instructions. + 2. Update README + release notes prominently. + 3. Existing installs cannot auto-update — they must reinstall manually. + +EOF + + step 5 "Do NOT trigger a smoke release until you've committed the pubkey change." +} + +# ── dispatcher ──────────────────────────────────────────────────────────────── + +usage() { + cat < + + app-store-connect-key Rotate notarization API key + apple-cert Rotate Developer ID Application cert + tauri-key Rotate Tauri updater signing key (DANGEROUS) + help This message + +Each subcommand walks the manual prep with checkpoints, delegates secret +upload to bootstrap-secrets.sh, and verifies the secret's updatedAt +timestamp changed. +EOF +} + +main() { + require_tools + case "${1:-help}" in + app-store-connect-key) cmd_app_store_connect_key ;; + apple-cert) cmd_apple_cert ;; + tauri-key) cmd_tauri_key ;; + help|-h|--help) usage ;; + *) usage; exit 1 ;; + esac +} + +main "$@"