From 3a1dbff384a77575c0d23fd39198fbcf58f13c52 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Wed, 28 Oct 2026 19:35:14 +0100 Subject: [PATCH] Never ship a macOS download Gatekeeper refuses The standalone macOS binaries were only signed ad hoc, so a browser download could only be moved to the Trash. They are now signed with the Codext GmbH Developer ID (hardened runtime, timestamp) and notarized. Official releases fail instead of shipping unsigned: the Apple secrets, the certificate's team and expiry are checked up front, and every DMG, app, update bundle and binary goes through scripts/macos-verify.sh. A daily workflow downloads what usegodmode.com serves, checks it the same way and opens an issue when a download would be refused. --- .github/workflows/release.yml | 81 +++++++++++++++-- .github/workflows/verify-downloads.yml | 65 ++++++++++++++ scripts/macos-sign-binaries.sh | 57 ++++++++++++ scripts/macos-verify.sh | 120 +++++++++++++++++++++++++ scripts/macos/binary.entitlements | 13 +++ 5 files changed, 328 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/verify-downloads.yml create mode 100755 scripts/macos-sign-binaries.sh create mode 100755 scripts/macos-verify.sh create mode 100644 scripts/macos/binary.entitlements 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 + + +