From dd2f566daab4c946c06e42c9b7d826d964005f45 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 6 Oct 2026 10:48:05 -0400 Subject: [PATCH] macOS CLI: verify the shipped CLI is Developer ID signed and notarized in the release workflow (#308) (#320) (#320) --- .github/workflows/release.yml | 9 ++ README.md | 8 ++ docs/releasing.md | 16 +++- packaging/macos/package.sh | 9 +- packaging/macos/verify.sh | 149 ++++++++++++++++++++++++++++++++++ 5 files changed, 186 insertions(+), 5 deletions(-) create mode 100755 packaging/macos/verify.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 49304e11f6..5577e1e874 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,6 +91,15 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} run: packaging/macos/package.sh --arch universal + # Unpacks the CLI zip and checks the binary users get: Developer ID, hardened runtime, + # timestamp, and Gatekeeper's notarization lookup (codesign --verify, spctl). Also the DMG's + # stapled ticket. Strict when the signing secrets are present, warnings otherwise. + - name: Verify signatures and notarization + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: packaging/macos/verify.sh --arch universal - uses: actions/upload-artifact@v4 with: name: release-macos diff --git a/README.md b/README.md index 321bc7e4fb..a75d9106ff 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,14 @@ flatpak install --user photocraft--linux-x86_64.flatpak # or -linux-a flatpak run ai.storyteller.photocraft ``` +On macOS, the command-line tool comes as `photocraft-cli--macos-universal.zip`. The binary is signed with the same Developer ID as the app and notarized by Apple. A bare binary can't carry a stapled notarization ticket the way the DMG does, so the first time you run it macOS checks the notarization online. You can confirm it yourself: + +```sh +ditto -x -k photocraft-cli--macos-universal.zip . +spctl --assess --type install -vv photocraft-cli--macos-universal/photocraft-cli +# ... accepted, source=Notarized Developer ID +``` + On FreeBSD 14 (x86_64), the release has a tarball laid out like `/usr/local`. Install the runtime libraries, then unpack it there: ```sh diff --git a/docs/releasing.md b/docs/releasing.md index daa4370335..61c08c461f 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -77,8 +77,20 @@ Every binary reports its version, the commit and the build date: `photocraft --v ticket is stapled to the app. The app goes on a DMG (`hdiutil`, with an `Applications` link to drag onto). The DMG is signed, notarized and stapled too. The script checks the results with `codesign --verify --strict`, `stapler validate` and `spctl -a -vvv`. -- **CLI:** the universal `photocraft-cli` is signed with the hardened runtime, zipped and - notarized. A bare binary can't hold a stapled ticket, so Gatekeeper looks it up online. +- **CLI:** the universal `photocraft-cli` is signed with the same Developer ID, the hardened + runtime and a secure timestamp (identifier `ai.storyteller.photocraft-cli`), zipped, and the zip + is sent to `notarytool`. Only `.app`, `.dmg` and `.pkg` can hold a stapled ticket, not a bare + Mach-O, so Gatekeeper looks the CLI's ticket up online the first time a downloaded + (quarantined) copy runs. Offline, that first run can be refused until the Mac is online again. +- **Verification:** `packaging/macos/verify.sh` checks the artifacts as users download them. It + unpacks the CLI zip and requires, for the binary inside, `codesign --verify --strict`, both + architectures, the hardened runtime flag, a `Developer ID Application` authority from + `APPLE_TEAM_ID`, a timestamp, and `spctl --assess --type install` reporting + `source=Notarized Developer ID` (the same online lookup Gatekeeper does). For the DMG it runs + `codesign --verify`, `stapler validate` and `spctl --type open`. The release workflow runs it + as its own step after packaging. With signing secrets it fails the job on any miss; without + them it only checks signature integrity and warns, like `package.sh`. Run it locally after a + build: `packaging/macos/verify.sh --arch aarch64`. Locally, without certificates, the script signs ad-hoc (`codesign -s -`) and skips notarization. That's enough to check the bundle and the DMG on your own Mac: diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index 9e5bcb2961..7e71a3ab2d 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -150,13 +150,16 @@ CLI_DIR="$WORK/photocraft-cli-$VERSION-macos-$ARCH" mkdir -p "$CLI_DIR" cp "$WORK/bin/photocraft-cli" "$CLI_DIR/" copy_docs "$CLI_DIR" -sign --options runtime "$CLI_DIR/photocraft-cli" +# Same Developer ID and hardened runtime as the app, with an explicit reverse-DNS identifier +# (codesign would otherwise use the bare file name). +sign --options runtime --identifier ai.storyteller.photocraft-cli "$CLI_DIR/photocraft-cli" codesign --verify --strict --verbose=2 "$CLI_DIR/photocraft-cli" rm -f "$CLI_ZIP" ditto -c -k --keepParent "$CLI_DIR" "$CLI_ZIP" -# A bare Mach-O can't carry a stapled ticket; Gatekeeper looks the notarization up online. +# notarytool accepts a zip of the bare binary. A bare Mach-O can't carry a stapled ticket, so +# Gatekeeper looks the notarization up online when a quarantined copy first runs. if [ "$NOTARIZE" = 1 ]; then notarize "$CLI_ZIP"; fi "$WORK/bin/photocraft-cli" --version -echo "==> done" +echo "==> done (check the artifacts as shipped with packaging/macos/verify.sh --arch $ARCH)" ls -lh "$DMG" "$CLI_ZIP" diff --git a/packaging/macos/verify.sh b/packaging/macos/verify.sh new file mode 100755 index 0000000000..496aad1174 --- /dev/null +++ b/packaging/macos/verify.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# Check the macOS release artifacts exactly as users download them: +# +# $DIST/photocraft--macos-.dmg signed, notarized, ticket stapled +# $DIST/photocraft-cli--macos-.zip unpacked here; the binary inside must be +# Developer ID signed with the hardened runtime, +# timestamped and notarized +# +# Usage: packaging/macos/verify.sh [--arch universal|aarch64|x86_64] +# +# With a real signing identity and notarization credentials in the environment (the same +# MACOS_SIGN_IDENTITY / APPLE_ID / APPLE_PASSWORD / APPLE_TEAM_ID that package.sh uses), every +# check is strict and a failure exits non-zero. Without them (local ad-hoc builds, or a release run +# whose secrets are missing) only the signature's integrity is checked and the rest is a warning, +# matching package.sh. Nothing here reads or prints a secret's value; it only tests whether one is set. +# +# A bare Mach-O can't carry a stapled notarization ticket (only .app, .dmg and .pkg can), so for the +# CLI Gatekeeper looks the ticket up online the first time a quarantined copy runs. `spctl --assess +# --type install` does that same online lookup here, which is how this script proves the CLI was +# notarized. +set -euo pipefail +# shellcheck source=../env.sh +. "$(dirname "${BASH_SOURCE[0]}")/../env.sh" + +ARCH=universal +while [ $# -gt 0 ]; do + case "$1" in + --arch) ARCH="$2"; shift 2 ;; + -h | --help) sed -n '2,20p' "$0"; exit 0 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +case "$ARCH" in + universal) WANT_ARCHS="x86_64 arm64" ;; + aarch64) WANT_ARCHS="arm64" ;; + x86_64) WANT_ARCHS="x86_64" ;; + *) echo "unknown --arch $ARCH" >&2; exit 2 ;; +esac + +DMG="$DIST/photocraft-$VERSION-macos-$ARCH.dmg" +CLI_ZIP="$DIST/photocraft-cli-$VERSION-macos-$ARCH.zip" +IDENTITY="${MACOS_SIGN_IDENTITY:--}" +STRICT=0 +if [ "$IDENTITY" != "-" ] && [ -n "${APPLE_ID:-}" ] && [ -n "${APPLE_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then + STRICT=1 +fi + +FAILED=0 +fail() { + if [ "$STRICT" = 1 ]; then + echo "error: $*" >&2 + FAILED=1 + else + warn "$* (expected without signing secrets)" + fi +} + +# spctl's online ticket lookup can lag a few seconds behind notarytool's "Accepted". +assess() { + local out attempt tries=1 + [ "$STRICT" = 1 ] && tries=5 + for attempt in $(seq "$tries"); do + if out="$(spctl "$@" 2>&1)"; then + printf '%s\n' "$out" + return 0 + fi + [ "$attempt" = "$tries" ] || sleep 10 + done + printf '%s\n' "$out" + return 1 +} + +for f in "$DMG" "$CLI_ZIP"; do + if [ ! -f "$f" ]; then + echo "error: missing $f" >&2 + exit 1 + fi +done + +WORK="$(mktemp -d "${RUNNER_TEMP:-${TMPDIR:-/tmp}}/photocraft-verify.XXXXXX")" +trap 'rm -rf "$WORK"' EXIT + +# ---- CLI zip ----------------------------------------------------------------------------------- +echo "==> $(basename "$CLI_ZIP")" +ditto -x -k "$CLI_ZIP" "$WORK/cli" +CLI="$WORK/cli/photocraft-cli-$VERSION-macos-$ARCH/photocraft-cli" +if [ ! -x "$CLI" ]; then + echo "error: $(basename "$CLI_ZIP") has no executable photocraft-cli-$VERSION-macos-$ARCH/photocraft-cli" >&2 + exit 1 +fi + +archs="$(lipo -archs "$CLI")" +for a in $WANT_ARCHS; do + case " $archs " in + *" $a "*) ;; + *) echo "error: photocraft-cli lacks $a (has: $archs)" >&2; exit 1 ;; + esac +done + +# Integrity first: this must hold even for ad-hoc builds. +codesign --verify --strict --verbose=2 "$CLI" +details="$(codesign -d --verbose=2 "$CLI" 2>&1)" +printf '%s\n' "$details" | grep -E '^(Identifier|Format|CodeDirectory|Authority|Timestamp|TeamIdentifier)[= ]' || true + +printf '%s\n' "$details" | grep -Eq '^CodeDirectory .*flags=0x[0-9a-f]+\([^)]*runtime' \ + || fail "photocraft-cli is not signed with the hardened runtime" +printf '%s\n' "$details" | grep -q '^Authority=Developer ID Application:' \ + || fail "photocraft-cli is not signed with a Developer ID Application identity" +printf '%s\n' "$details" | grep -q '^Timestamp=' \ + || fail "photocraft-cli signature has no secure timestamp" +if [ -n "${APPLE_TEAM_ID:-}" ]; then + printf '%s\n' "$details" | grep -qx "TeamIdentifier=$APPLE_TEAM_ID" \ + || fail "photocraft-cli is signed by a different team" +fi +if gk="$(assess --assess --type install -vv "$CLI")"; then + printf '%s\n' "$gk" + printf '%s\n' "$gk" | grep -q 'source=Notarized Developer ID' \ + || fail "Gatekeeper accepts photocraft-cli but not as notarized" +else + printf '%s\n' "$gk" + fail "Gatekeeper rejects photocraft-cli (not notarized?)" +fi +"$CLI" --version + +# ---- DMG --------------------------------------------------------------------------------------- +echo "==> $(basename "$DMG")" +codesign --verify --strict --verbose=2 "$DMG" +if [ "$STRICT" = 1 ]; then + xcrun stapler validate "$DMG" || fail "no notarization ticket stapled to the DMG" + assess --assess --type open --context context:primary-signature -vv "$DMG" \ + || fail "Gatekeeper rejects the DMG" +fi + +if [ "$FAILED" != 0 ]; then + echo "error: macOS artifacts failed verification" >&2 + exit 1 +fi + +if [ "$STRICT" = 1 ]; then + result="signed (Developer ID, hardened runtime), notarized; DMG stapled" +else + result="signature intact; Developer ID and notarization not required (no signing secrets)" +fi +echo "==> verified: $result" +if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "- macOS \`$(basename "$DMG")\`, \`$(basename "$CLI_ZIP")\`: $result" + } >>"$GITHUB_STEP_SUMMARY" +fi