diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68c72852..93018453 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,6 +13,10 @@ name: Release # APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, APPLE_TEAM_ID and for notarization either an App Store Connect # API key (APPLE_API_ISSUER, APPLE_API_KEY = key id, APPLE_API_KEY_P8 = contents of the .p8) or APPLE_ID + APPLE_PASSWORD. # +# Official releases fail instead of shipping anything unsigned: the DMGs, the apps inside them, the update bundles and +# the standalone macOS binaries are all checked with scripts/macos-verify.sh, and verify-downloads.yml re-checks what +# usegodmode.com serves every morning (it opens an issue when Gatekeeper would refuse a download). +# # Required secrets for auto-update: TAURI_SIGNING_PRIVATE_KEY + TAURI_SIGNING_PRIVATE_KEY_PASSWORD (the key matching # `plugins.updater.pubkey` in tauri.conf.json). They sign the update bundles and add latest.json to the release; # installed apps pick the update up once the release is published. @@ -91,6 +95,41 @@ jobs: exit 1 fi + - name: Check the Apple signing setup + if: github.repository == 'codextde/godmode-bot' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + # An official release is never shipped unsigned: macOS would only offer to move it to the Trash. + missing=0 + for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_TEAM_ID APPLE_API_ISSUER APPLE_API_KEY APPLE_API_KEY_P8; do + if [ -z "${!name}" ]; then echo "::error::the $name secret is missing (see the top of release.yml)"; missing=1; fi + done + [ "$missing" = 0 ] || exit 1 + [[ "$APPLE_SIGNING_IDENTITY" == "Developer ID Application: "*"($APPLE_TEAM_ID)" || "$APPLE_SIGNING_IDENTITY" =~ ^[0-9A-Fa-f]{40}$ ]] || + { echo "::error::APPLE_SIGNING_IDENTITY must be a Developer ID Application identity of team $APPLE_TEAM_ID"; exit 1; } + printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/cert.p12" + export PW="$APPLE_CERTIFICATE_PASSWORD" + openssl pkcs12 -in "$RUNNER_TEMP/cert.p12" -passin env:PW -nokeys -clcerts -out "$RUNNER_TEMP/cert.pem" 2>/dev/null || + openssl pkcs12 -legacy -in "$RUNNER_TEMP/cert.p12" -passin env:PW -nokeys -clcerts -out "$RUNNER_TEMP/cert.pem" || + { echo "::error::APPLE_CERTIFICATE can't be opened with APPLE_CERTIFICATE_PASSWORD"; exit 1; } + subject=$(openssl x509 -in "$RUNNER_TEMP/cert.pem" -noout -subject) + until=$(openssl x509 -in "$RUNNER_TEMP/cert.pem" -noout -enddate | cut -d= -f2) + rm -f "$RUNNER_TEMP/cert.p12" + [[ "$subject" == *"$APPLE_TEAM_ID"* ]] || { echo "::error::APPLE_CERTIFICATE belongs to another team: $subject"; exit 1; } + openssl x509 -in "$RUNNER_TEMP/cert.pem" -noout -checkend 0 >/dev/null || + { echo "::error::the Developer ID certificate expired on $until: create a new one on developer.apple.com and update APPLE_CERTIFICATE"; exit 1; } + openssl x509 -in "$RUNNER_TEMP/cert.pem" -noout -checkend $((45 * 86400)) >/dev/null || + echo "::warning::the Developer ID certificate expires on $until: renew it before then" + rm -f "$RUNNER_TEMP/cert.pem" + echo "Signing as $APPLE_SIGNING_IDENTITY, certificate valid until $until" + - name: Create (or reuse) the draft release id: release env: @@ -201,7 +240,11 @@ jobs: printf '%s\n' "$APPLE_API_KEY_P8" > "$key_path" echo "APPLE_API_KEY_PATH=$key_path" >> "$GITHUB_ENV" fi - if [ -z "$APPLE_CERTIFICATE" ]; then + if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$APPLE_API_KEY_P8" ]; then + if [ "$GITHUB_REPOSITORY" = "codextde/godmode-bot" ]; then + echo "::error::the Apple signing secrets are missing: an unsigned app can only be moved to the Trash on a Mac" + exit 1 + fi echo "::warning::macOS build is not signed with a Developer ID (APPLE_CERTIFICATE missing): Gatekeeper will block it" fi @@ -236,11 +279,9 @@ jobs: - name: Check the macOS signature and notarization if: runner.os == 'macOS' && env.APPLE_CERTIFICATE != '' run: | - app=$(ls -d apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/macos/*.app | head -n1) - codesign --verify --deep --strict --verbose=2 "$app" - codesign -dvv "$app" 2>&1 | grep -E "Authority=Developer ID Application|TeamIdentifier" - xcrun stapler validate "$app" - spctl --assess --type execute --verbose=2 "$app" + bundle=apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle + apps=("$bundle"/macos/*.app) updates=("$bundle"/macos/*.app.tar.gz) + scripts/macos-verify.sh "${apps[0]}" "${updates[0]}" # The bundler notarizes the .app only; a downloaded DMG that is signed but not notarized can still warn on open. - name: Notarize the DMG and replace it in the release @@ -252,7 +293,8 @@ jobs: dmg=$(ls apps/desktop/src-tauri/target/${{ matrix.rust_target }}/release/bundle/dmg/*.dmg | head -n1) xcrun notarytool submit "$dmg" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" --wait --timeout 30m xcrun stapler staple "$dmg" - spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg" + # What a user downloads: the DMG and the app inside it, as Gatekeeper sees them. + scripts/macos-verify.sh "$dmg" # GitHub stores spaces in asset names as dots. name=$(basename "$dmg" | tr ' ' '.') old=$(gh api "repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?per_page=100" --jq ".[] | select(.name == \"$name\") | .id") @@ -314,7 +356,7 @@ jobs: fail-fast: false matrix: include: - # Built on Linux: Linux + Windows. macOS binaries are built (and signed ad hoc) on macOS. + # Built on Linux: Linux + Windows. macOS binaries are built, signed and notarized on macOS. - os: ubuntu-latest targets: bun-linux-x64 bun-linux-x64-baseline bun-linux-arm64 bun-windows-x64 - os: macos-latest @@ -340,6 +382,29 @@ jobs: bun run scripts/build.ts --target="$target" --smoke done + - name: Sign and notarize the macOS binaries + if: runner.os == 'macOS' + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + run: | + # Ad-hoc signed binaries downloaded in a browser can only be moved to the Trash. + if [ -z "$APPLE_CERTIFICATE" ] || [ -z "$APPLE_API_KEY_P8" ]; then + if [ "$GITHUB_REPOSITORY" = "codextde/godmode-bot" ]; then echo "::error::the Apple signing secrets are missing"; exit 1; fi + echo "::warning::macOS binaries stay ad-hoc signed (no Apple signing secrets)" + exit 0 + fi + export APPLE_API_KEY_PATH="$RUNNER_TEMP/AuthKey_$APPLE_API_KEY.p8" + printf '%s\n' "$APPLE_API_KEY_P8" > "$APPLE_API_KEY_PATH" + scripts/macos-sign-binaries.sh packages/core/bin/godmode-darwin-* + packages/core/bin/godmode-darwin-arm64 version + scripts/macos-verify.sh packages/core/bin/godmode-darwin-* + - name: Upload to the draft release working-directory: packages/core/bin env: diff --git a/.github/workflows/verify-downloads.yml b/.github/workflows/verify-downloads.yml new file mode 100644 index 00000000..727c7e01 --- /dev/null +++ b/.github/workflows/verify-downloads.yml @@ -0,0 +1,65 @@ +name: Verify macOS downloads + +# What a Mac user gets from usegodmode.com and the in-app updater must open without a Gatekeeper prompt: +# Developer ID Application: Codext GmbH, notarized, stapled. Runs every morning and after each published release; +# a failure opens (or updates) an issue so a broken signature never stays unnoticed. + +on: + schedule: + - cron: "17 5 * * *" + release: + types: [published] + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + verify: + name: Gatekeeper check + runs-on: macos-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Download what users download + run: | + mkdir -p "$RUNNER_TEMP/dl" && cd "$RUNNER_TEMP/dl" + # The website picks the latest published release; a release event may beat its five-minute cache. + if [ "${{ github.event_name }}" = release ]; then sleep 360; fi + for platform in mac mac-intel; do + curl -fsSL --retry 3 -o "$platform.dmg" "https://usegodmode.com/download/$platform" + done + for platform in server-macos-arm64 server-macos-x64; do + curl -fsSL --retry 3 -o "godmode-$platform" "https://usegodmode.com/download/$platform" + chmod +x "godmode-$platform" + done + curl -fsSL --retry 3 -o latest.json "https://usegodmode.com/api/update/latest.json" || + gh release download --repo "$GITHUB_REPOSITORY" --pattern latest.json --clobber + for arch in darwin-aarch64 darwin-x86_64; do + url=$(jq -r --arg p "$arch" '.platforms[$p].url' latest.json) + curl -fsSL --retry 3 -o "update-$arch.app.tar.gz" "$url" + done + # Mark everything as downloaded by a browser, like a user's copy. + for f in *; do xattr -w com.apple.quarantine "0081;$(printf '%x' "$(date +%s)");Safari;" "$f"; done + ls -la + env: + GH_TOKEN: ${{ github.token }} + + - name: Check signatures and notarization + run: scripts/macos-verify.sh "$RUNNER_TEMP"/dl/*.dmg "$RUNNER_TEMP"/dl/*.app.tar.gz "$RUNNER_TEMP"/dl/godmode-server-* + + - name: Open an issue when a download is broken + if: failure() && github.event_name != 'workflow_dispatch' + env: + GH_TOKEN: ${{ github.token }} + run: | + title="macOS downloads fail the Gatekeeper check" + body="The [Verify macOS downloads]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID) run found a download that macOS would refuse to open. Check the log, fix the signing (see the top of release.yml) and re-run the release." + number=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "\"$title\" in:title" --json number --jq '.[0].number // empty') + if [ -n "$number" ]; then + gh issue comment "$number" --repo "$GITHUB_REPOSITORY" --body "$body" + else + gh issue create --repo "$GITHUB_REPOSITORY" --title "$title" --body "$body" + fi diff --git a/scripts/macos-sign-binaries.sh b/scripts/macos-sign-binaries.sh new file mode 100755 index 00000000..82f2c36f --- /dev/null +++ b/scripts/macos-sign-binaries.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Signs standalone macOS binaries with the Developer ID (hardened runtime, timestamp) and notarizes them, so a +# browser download opens without "can't be opened, move to the Trash". A bare binary can't carry a stapled ticket: +# Gatekeeper looks the notarization up online. +# +# scripts/macos-sign-binaries.sh ... +# +# Env: APPLE_CERTIFICATE (base64 .p12), APPLE_CERTIFICATE_PASSWORD, APPLE_SIGNING_IDENTITY, +# APPLE_API_KEY_PATH (.p8), APPLE_API_KEY (key id), APPLE_API_ISSUER. +set -euo pipefail + +for name in APPLE_CERTIFICATE APPLE_CERTIFICATE_PASSWORD APPLE_SIGNING_IDENTITY APPLE_API_KEY_PATH APPLE_API_KEY APPLE_API_ISSUER; do + [ -n "${!name:-}" ] || { echo "::error::$name is not set"; exit 1; } +done + +here="$(cd "$(dirname "$0")" && pwd)" +work="$(mktemp -d)" +keychain="$work/signing.keychain-db" +pass="$(openssl rand -hex 16)" +old_keychains=() +while IFS= read -r line; do + line="${line#"${line%%[![:space:]]*}"}" + line="${line#\"}" + line="${line%\"}" + [ -n "$line" ] && old_keychains+=("$line") +done < <(security list-keychains -d user) +cleanup() { + security list-keychains -d user -s "${old_keychains[@]}" >/dev/null 2>&1 || true + security delete-keychain "$keychain" >/dev/null 2>&1 || true + rm -rf "$work" +} +trap cleanup EXIT + +printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$work/cert.p12" +security create-keychain -p "$pass" "$keychain" +security set-keychain-settings -lut 3600 "$keychain" +security unlock-keychain -p "$pass" "$keychain" +security import "$work/cert.p12" -k "$keychain" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign >/dev/null +security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$pass" "$keychain" >/dev/null +security list-keychains -d user -s "$keychain" "${old_keychains[@]}" + +for bin in "$@"; do + codesign --force --options runtime --timestamp --entitlements "$here/macos/binary.entitlements" \ + --keychain "$keychain" --sign "$APPLE_SIGNING_IDENTITY" "$bin" + zip="$work/$(basename "$bin").zip" + ditto -c -k --keepParent "$bin" "$zip" + result="$(xcrun notarytool submit "$zip" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" \ + --wait --timeout 30m --output-format json)" + status="$(plutil -extract status raw -o - - <<<"$result" 2>/dev/null || echo unknown)" + if [ "$status" != "Accepted" ]; then + id="$(plutil -extract id raw -o - - <<<"$result" 2>/dev/null || true)" + echo "::error::notarization of $bin: $status" + [ -n "$id" ] && xcrun notarytool log "$id" --key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY" --issuer "$APPLE_API_ISSUER" || true + exit 1 + fi + echo "signed and notarized: $bin" +done diff --git a/scripts/macos-verify.sh b/scripts/macos-verify.sh new file mode 100755 index 00000000..6c5078a9 --- /dev/null +++ b/scripts/macos-verify.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Fails unless every given macOS download opens without a Gatekeeper prompt: signed with the Developer ID of +# Codext GmbH, hardened runtime, secure timestamp, and (apps and DMGs) notarized with a stapled ticket. +# +# scripts/macos-verify.sh ... +# +# APPLE_TEAM_ID (default STSYBH724K) is the team the signature must belong to; CERT_WARN_DAYS (default 45) warns +# when the signing certificate runs out soon, and the check fails once it has. +set -euo pipefail + +TEAM="${APPLE_TEAM_ID:-STSYBH724K}" +WARN_DAYS="${CERT_WARN_DAYS:-45}" +WORK="$(mktemp -d)" +MOUNTS=() +cleanup() { + for m in "${MOUNTS[@]:-}"; do [ -n "$m" ] && hdiutil detach "$m" -quiet -force 2>/dev/null || true; done + rm -rf "$WORK" +} +trap cleanup EXIT + +fail() { + echo "::error::$*" + exit 1 +} + +signer() { + local path="$1" kind="${2:-code}" info + info="$(codesign -dvv "$path" 2>&1)" || fail "$path is not signed" + grep -q "^Authority=Developer ID Application: .*($TEAM)$" <<<"$info" || + fail "$path is not signed with a Developer ID of team $TEAM ($(grep -m1 -E '^(Authority|Signature)=' <<<"$info" || echo unsigned))" + grep -q "^TeamIdentifier=$TEAM$" <<<"$info" || fail "$path has another team identifier" + [ "$kind" = disk ] || grep -q "flags=.*runtime" <<<"$info" || fail "$path is signed without the hardened runtime (notarization needs it)" + grep -q "^Timestamp=" <<<"$info" || fail "$path has no secure timestamp" + codesign --verify --strict --verbose=2 "$path" + certificate "$path" + echo " signed: $(grep -m1 '^Authority=' <<<"$info" | cut -d= -f2-)" +} + +certificate() { + local dir="$WORK/certs-$RANDOM" + mkdir -p "$dir" + (cd "$dir" && codesign -d --extract-certificates "$1" 2>/dev/null) + local leaf="$dir/codesign0" + [ -f "$leaf" ] || fail "could not read the signing certificate of $1" + local until + until="$(openssl x509 -inform DER -in "$leaf" -noout -enddate | cut -d= -f2)" + # The signatures stay valid after expiry thanks to the timestamp, but new releases can no longer be signed. + openssl x509 -inform DER -in "$leaf" -noout -checkend 0 >/dev/null || fail "the Developer ID certificate expired on $until: create a new one (see the top of release.yml)" + if ! openssl x509 -inform DER -in "$leaf" -noout -checkend $((WARN_DAYS * 86400)) >/dev/null; then + echo "::warning::the Developer ID certificate expires on $until: renew it and update APPLE_CERTIFICATE before then" + fi +} + +gatekeeper() { + local out + out="$(spctl --assess --verbose=2 "$@" 2>&1)" || fail "Gatekeeper rejects ${*: -1}: $out" + grep -q "source=Notarized Developer ID" <<<"$out" || fail "${*: -1} is not notarized: $out" +} + +app() { + local path="$1" + echo "app: $path" + signer "$path" + codesign --verify --deep --strict --verbose=2 "$path" + xcrun stapler validate "$path" >/dev/null || fail "$path has no stapled notarization ticket" + gatekeeper --type execute "$path" + if command -v syspolicy_check >/dev/null; then + syspolicy_check distribution "$path" || fail "syspolicy_check rejects $path" + fi +} + +dmg() { + local path="$1" mnt + echo "dmg: $path" + signer "$path" disk + xcrun stapler validate "$path" >/dev/null || fail "$path has no stapled notarization ticket" + gatekeeper --type open --context context:primary-signature "$path" + mnt="$WORK/mnt-$RANDOM" + mkdir -p "$mnt" + # The DMG carries a licence agreement: `yes` accepts it like a click on Agree. + hdiutil attach -nobrowse -readonly -noautoopen -mountpoint "$mnt" "$path" < <(yes) >/dev/null || fail "could not open $path" + MOUNTS+=("$mnt") + local found=0 + for a in "$mnt"/*.app; do + [ -d "$a" ] || continue + found=1 + app "$a" + done + [ "$found" = 1 ] || fail "$path contains no app" +} + +tarball() { + local path="$1" dir="$WORK/tar-$RANDOM" + echo "update bundle: $path" + mkdir -p "$dir" + tar -xzf "$path" -C "$dir" + local found=0 + for a in "$dir"/*.app; do + [ -d "$a" ] || continue + found=1 + app "$a" + done + [ "$found" = 1 ] || fail "$path contains no app" +} + +[ $# -gt 0 ] || fail "usage: $0 ..." +for target in "$@"; do + [ -e "$target" ] || fail "$target does not exist" + case "$target" in + *.dmg) dmg "$target" ;; + *.app) app "$target" ;; + *.tar.gz) tarball "$target" ;; + *) + # A command-line binary: Gatekeeper checks its notarization online (a ticket can't be stapled to it). + echo "binary: $target" + signer "$target" + ;; + esac +done +echo "All macOS downloads pass Gatekeeper." diff --git a/scripts/macos/binary.entitlements b/scripts/macos/binary.entitlements new file mode 100644 index 00000000..bf035244 --- /dev/null +++ b/scripts/macos/binary.entitlements @@ -0,0 +1,13 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + +