From 87a2c42d73331fcc0169c017a554cfec77323555 Mon Sep 17 00:00:00 2001 From: Rohan Pandula Date: Fri, 14 Aug 2026 14:33:31 -0700 Subject: [PATCH 1/2] macOS releases: Developer ID signing, notarization, and stapling Every release so far shipped ad-hoc-signed, unnotarized DMGs -- the longest-standing known limitation on the release train. With the Apple Developer account in place, the release workflow now produces Gatekeeper-clean builds: - package_app.sh's signing block is identity-parameterized (SCANSTUDIO_SIGNING_IDENTITY, default "-"): local builds and PR CI keep the exact ad-hoc behavior they have always had, while a real identity switches to notarization-grade signing -- every Mach-O in the bundle signed individually with a secure timestamp (notarytool rejects any unsigned Mach-O, and --deep never descends into Resources where the whole BridgeRuntime CPython tree lives), hardened runtime on every executable, and executables carrying the one entitlement the bridge's ctypes loader needs to dlopen the bundled same-team-signed libusb under library validation. - New scripts/notarize_dmg.sh signs the DMG, submits it through notarytool with the App Store Connect API key, treats anything but "status: Accepted" as failure (notarytool can exit 0 on a rejected submission -- the status line is the authority, and the per-file rejection log is fetched before failing), staples, and finishes with Gatekeeper's own spctl verdict on the stapled artifact. - The release DMG job imports the certificate into a throwaway keychain (deleted in an always() cleanup step), fails loudly when any signing secret is missing rather than silently shipping another ad-hoc build, and notarizes BEFORE SHA256SUMS/latest.json are emitted -- stapling mutates the DMG, so every published checksum is computed from the stapled artifact. Secrets consumed (names only; values live in repository secrets): MACOS_SIGNING_CERT_P12_BASE64, MACOS_SIGNING_CERT_PASSWORD, APPSTORE_CONNECT_API_KEY_P8, APPSTORE_CONNECT_API_KEY_ID, APPSTORE_CONNECT_API_ISSUER_ID. The GitHub Actions pin policy passes over the edited workflow (the new steps are plain run: steps); both zsh scripts pass zsh -n; the ad-hoc default path is exercised by the existing PR-CI package jobs. --- .github/workflows/release.yml | 55 +++++++++++++++++++++++++- app/ScanStudio/scripts/notarize_dmg.sh | 47 ++++++++++++++++++++++ app/ScanStudio/scripts/package_app.sh | 51 ++++++++++++++++++++++-- 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100755 app/ScanStudio/scripts/notarize_dmg.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 05a536a..0eac0b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,14 +101,59 @@ jobs: echo "RELEASE_VERSION=$STAMP" >> "$GITHUB_ENV" echo "SCANSTUDIO_RELEASE_VERSION=$STAMP" >> "$GITHUB_ENV" echo "Resolved release version: $STAMP" + - name: Import the Developer ID signing identity + # A release build must be signed for distribution: this job fails + # loudly when the signing secrets are absent rather than silently + # shipping another ad-hoc build. The certificate lives only in a + # throwaway keychain deleted in the always() cleanup below; the + # identity string (not a secret) is exported for package_app.sh. + env: + P12_B64: ${{ secrets.MACOS_SIGNING_CERT_P12_BASE64 }} + CERT_PW: ${{ secrets.MACOS_SIGNING_CERT_PASSWORD }} + run: | + test -n "$P12_B64" || { echo "MACOS_SIGNING_CERT_P12_BASE64 secret is missing or empty"; exit 1; } + test -n "$CERT_PW" || { echo "MACOS_SIGNING_CERT_PASSWORD secret is missing or empty"; exit 1; } + KEYCHAIN="$RUNNER_TEMP/scanstudio-signing.keychain-db" + KEYCHAIN_PW="$(uuidgen)" + security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + printf '%s' "$P12_B64" | base64 -d > "$RUNNER_TEMP/signing.p12" + security import "$RUNNER_TEMP/signing.p12" -k "$KEYCHAIN" -P "$CERT_PW" -T /usr/bin/codesign + rm -f "$RUNNER_TEMP/signing.p12" + security set-key-partition-list -S 'apple-tool:,apple:' -s -k "$KEYCHAIN_PW" "$KEYCHAIN" > /dev/null + security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk -F'"' '/Developer ID Application/ {print $2; exit}')" + test -n "$IDENTITY" || { echo "no Developer ID Application identity found in the imported certificate"; exit 1; } + echo "SCANSTUDIO_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "Imported signing identity: $IDENTITY" - name: Build and verify the local DMG # package_dmg.sh names the artifact ScanStudio--macOS-.dmg # (arch from uname -m, i.e. this runner's native arch) and refuses to # overwrite an existing artifact. It also verifies the mounted app's # code signature and runs test_packaged_bridge.sh on it; any failure - # here aborts the job and nothing is released. + # here aborts the job and nothing is released. With + # SCANSTUDIO_SIGNING_IDENTITY exported above, package_app.sh signs to + # notarization requirements instead of ad-hoc. working-directory: app/ScanStudio run: make dmg + - name: Notarize and staple the DMG + # MUST run before SHA256SUMS/latest.json are emitted: stapling + # mutates the DMG, so every published checksum has to be computed + # from the stapled artifact. + env: + NOTARY_KEY_B64: ${{ secrets.APPSTORE_CONNECT_API_KEY_P8 }} + NOTARY_KEY_ID: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_API_ISSUER_ID }} + working-directory: app/ScanStudio + run: | + test -n "$NOTARY_KEY_B64" || { echo "APPSTORE_CONNECT_API_KEY_P8 secret is missing or empty"; exit 1; } + test -n "$NOTARY_KEY_ID" || { echo "APPSTORE_CONNECT_API_KEY_ID secret is missing or empty"; exit 1; } + test -n "$NOTARY_ISSUER_ID" || { echo "APPSTORE_CONNECT_API_ISSUER_ID secret is missing or empty"; exit 1; } + export NOTARY_KEY_FILE="$RUNNER_TEMP/notary-key.p8" + printf '%s' "$NOTARY_KEY_B64" > "$NOTARY_KEY_FILE" + zsh scripts/notarize_dmg.sh ".build/ScanStudio-$RELEASE_VERSION-macOS-$(uname -m).dmg" + rm -f "$NOTARY_KEY_FILE" - name: Emit SHA256SUMS and arch-keyed latest.json working-directory: app/ScanStudio run: | @@ -147,6 +192,14 @@ jobs: app/ScanStudio/.build/latest.json if-no-files-found: error retention-days: 1 + - name: Delete the throwaway signing keychain + if: always() + run: | + KEYCHAIN="$RUNNER_TEMP/scanstudio-signing.keychain-db" + if [ -f "$KEYCHAIN" ]; then + security list-keychains -d user -s login.keychain-db + security delete-keychain "$KEYCHAIN" + fi windows-resources: name: Assemble Windows offline resources diff --git a/app/ScanStudio/scripts/notarize_dmg.sh b/app/ScanStudio/scripts/notarize_dmg.sh new file mode 100755 index 0000000..f26eedb --- /dev/null +++ b/app/ScanStudio/scripts/notarize_dmg.sh @@ -0,0 +1,47 @@ +#!/bin/zsh +set -euo pipefail + +# Signs, notarizes, and staples one release DMG, then proves Gatekeeper +# accepts it. Runs only in the release workflow, after package_dmg.sh built +# the DMG from the Developer-ID-signed app and BEFORE SHA256SUMS/latest.json +# are emitted -- stapling mutates the DMG, so every checksum must be +# computed after this script succeeds. +# +# Required environment: +# SCANSTUDIO_SIGNING_IDENTITY Developer ID Application identity string +# NOTARY_KEY_FILE path to the App Store Connect API .p8 key +# NOTARY_KEY_ID the key id for that key +# NOTARY_ISSUER_ID the issuer id for the team + +dmg="${1:?usage: notarize_dmg.sh }" +[[ -f "$dmg" ]] || { print -u2 "no such DMG: $dmg"; exit 1 } +: "${SCANSTUDIO_SIGNING_IDENTITY:?}" "${NOTARY_KEY_FILE:?}" "${NOTARY_KEY_ID:?}" "${NOTARY_ISSUER_ID:?}" + +codesign --force --sign "$SCANSTUDIO_SIGNING_IDENTITY" --timestamp "$dmg" + +# notarytool can exit 0 while reporting a rejected submission, so the status +# line in its output is the authority; on anything but Accepted, fetch the +# per-file log (the only place the actual rejection reasons live) and fail. +submission="$(xcrun notarytool submit "$dmg" \ + --key "$NOTARY_KEY_FILE" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER_ID" \ + --wait --timeout 45m 2>&1)" +print -r -- "$submission" +if [[ "$submission" != *"status: Accepted"* ]]; then + submission_id="$(print -r -- "$submission" | awk '/^[[:space:]]*id: /{print $2; exit}')" + if [[ -n "$submission_id" ]]; then + xcrun notarytool log "$submission_id" \ + --key "$NOTARY_KEY_FILE" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER_ID" || true + fi + print -u2 "notarization was not accepted" + exit 1 +fi + +xcrun stapler staple "$dmg" +# Gatekeeper's own verdict on the stapled DMG -- the check a user's machine +# will effectively run on first open. +spctl -a -t open --context context:primary-signature -v "$dmg" +print "Notarized and stapled $dmg" diff --git a/app/ScanStudio/scripts/package_app.sh b/app/ScanStudio/scripts/package_app.sh index 64d8d66..83f306f 100755 --- a/app/ScanStudio/scripts/package_app.sh +++ b/app/ScanStudio/scripts/package_app.sh @@ -628,11 +628,54 @@ fi # before signing so the distributed binary contains no builder filesystem # paths. This does not alter executable code. strip -S "$staged_app/Contents/MacOS/scanstudio-engine" "$staged_app/Contents/MacOS/ScanStudio" -codesign --force --sign - "$bundled_libusb" + +# Signing identity: "-" (the default) keeps the ad-hoc signature every local +# and PR-CI build has always produced. The release workflow exports the +# Developer ID Application identity it imported into a throwaway keychain, +# which switches this block to notarization-grade signing: every Mach-O in +# the bundle signed individually with a secure timestamp (notarytool rejects +# any unsigned Mach-O, and --deep never descends into Resources, where the +# whole BridgeRuntime CPython tree lives), hardened runtime on every +# executable, and executables carrying the one entitlement the bridge's +# ctypes loader needs to dlopen the bundled (same-team-signed) libusb under +# library validation. +signing_identity="${SCANSTUDIO_SIGNING_IDENTITY:--}" +timestamp_flags=() +runtime_flags=() +if [[ "$signing_identity" != "-" ]]; then + timestamp_flags=(--timestamp) + runtime_flags=(--options runtime) + bridge_entitlements="$staging_root/bridge-runtime.entitlements" + cat > "$bridge_entitlements" <<'PLIST' + + + + + com.apple.security.cs.disable-library-validation + + + +PLIST + while IFS= read -r -d '' candidate; do + candidate_type="$(file -b "$candidate")" + [[ "$candidate_type" == *Mach-O* ]] || continue + if [[ "$candidate_type" == *executable* ]]; then + codesign --force --sign "$signing_identity" --timestamp --options runtime \ + --entitlements "$bridge_entitlements" "$candidate" + else + codesign --force --sign "$signing_identity" --timestamp "$candidate" + fi + done < <(find "$staged_app/Contents/Resources" "$staged_app/Contents/Frameworks" \ + -type f -print0 2>/dev/null) +fi +codesign --force --sign "$signing_identity" "${timestamp_flags[@]}" "$bundled_libusb" codesign --verify --strict "$bundled_libusb" -codesign --force --sign - "$staged_app/Contents/MacOS/scanstudio-engine" -codesign --force --sign - "$staged_app/Contents/MacOS/ScanStudio" -codesign --force --deep --sign - "$staged_app" +codesign --force --sign "$signing_identity" "${timestamp_flags[@]}" "${runtime_flags[@]}" \ + "$staged_app/Contents/MacOS/scanstudio-engine" +codesign --force --sign "$signing_identity" "${timestamp_flags[@]}" "${runtime_flags[@]}" \ + "$staged_app/Contents/MacOS/ScanStudio" +codesign --force --deep --sign "$signing_identity" "${timestamp_flags[@]}" "${runtime_flags[@]}" \ + "$staged_app" codesign --verify --deep --strict "$staged_app" replacement_started=1 From 599825539c0a88f207a577b0fecf334c6a86cb4b Mon Sep 17 00:00:00 2001 From: Rohan Pandula Date: Fri, 14 Aug 2026 14:56:42 -0700 Subject: [PATCH 2/2] Signing round 2: notarize the app itself, stamp the updater Team ID, harden the pipeline Two independent adversarial reviews of the first commit; every required finding addressed: - The .app is now notarized and stapled BEFORE the DMG is built (then the DMG again, as before): the in-app updater's publisher-trust gate requires a stapled ticket on the INSTALLED app -- a DMG ticket does not travel with an app copied out of it -- and an app-level ticket is also what makes offline first launch work. notarize_dmg.sh is generalized into notarize_artifact.sh (app|dmg modes; apps submit as a ditto zip and staple onto the bundle). - packaging/Info.plist stamps ScanStudioUpdateTeamIdentifier with the real Team ID -- the second independent blocker on the same updater gate, called out by docs/AUTO-UPDATE.md and skipped by the first commit. - The notarytool submission capture no longer loses its diagnostics when notarytool exits non-zero (auth failure, network error, timeout): the capture tolerates the exit code and the status line in the output stays the single authority, so the log always shows WHY. - The .p8 encoding contract is explicit: the secret is raw PEM (env renamed from _B64, misleading), written once per job with a BEGIN PRIVATE KEY guard that fails in seconds instead of after a 40-minute build; stapler validate runs after every staple (the direct ticket check spctl cannot make); key material is also removed in the always() cleanup; the release job gains a timeout now that it waits on Apple round-trips. - New signing-dry-run.yml (workflow_dispatch): rehearses the ENTIRE path on one arch without tagging -- keychain import, notarization-grade package, an assertion that the disable-library-validation entitlement landed on the bridge interpreter and ONLY there, app notarize + staple, a live proof that the hardened entitled interpreter can ctypes-dlopen the bundled libusb (the check the notary service cannot make), DMG build/notarize/staple, artifact upload for manual inspection. The release path's first execution is no longer a published tag. - The entitlement's justification comment now cites the real reason (host libsane via find_library is foreign-team-signed -- exactly what library validation blocks; the bundled same-team libusb would pass on its own); the Mach-O sweep gains a fork-saving pre-filter (executable-bit or *.so/*.dylib; file(1) stays the authority) and a provenance note that release signing rewrites files whose hashes were pinned pre-signing; package_dmg.sh labels its digest pre-staple; docs/AUTO-UPDATE.md now describes the shipped trust state and the real secret names. --- .github/workflows/release.yml | 55 ++++++--- .github/workflows/signing-dry-run.yml | 128 ++++++++++++++++++++ app/ScanStudio/packaging/Info.plist | 2 + app/ScanStudio/scripts/notarize_artifact.sh | 82 +++++++++++++ app/ScanStudio/scripts/notarize_dmg.sh | 47 ------- app/ScanStudio/scripts/package_app.sh | 17 ++- app/ScanStudio/scripts/package_dmg.sh | 5 +- docs/AUTO-UPDATE.md | 33 ++--- 8 files changed, 288 insertions(+), 81 deletions(-) create mode 100644 .github/workflows/signing-dry-run.yml create mode 100755 app/ScanStudio/scripts/notarize_artifact.sh delete mode 100755 app/ScanStudio/scripts/notarize_dmg.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0eac0b1..0175760 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,6 +32,9 @@ jobs: release: name: Build and verify ${{ matrix.arch }} DMG + # Bounded because this job now waits on two Apple notarization + # round-trips (app + DMG) on top of the build itself. + timeout-minutes: 150 needs: authorize # A universal bundle is impossible because the pinned cv2/numpy wheels are # not universal, so each architecture is built natively on its own runner @@ -127,33 +130,54 @@ jobs: test -n "$IDENTITY" || { echo "no Developer ID Application identity found in the imported certificate"; exit 1; } echo "SCANSTUDIO_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" echo "Imported signing identity: $IDENTITY" + - name: Write the notarization API key + env: + NOTARY_KEY_P8: ${{ secrets.APPSTORE_CONNECT_API_KEY_P8 }} + run: | + test -n "$NOTARY_KEY_P8" || { echo "APPSTORE_CONNECT_API_KEY_P8 secret is missing or empty"; exit 1; } + NOTARY_KEY_FILE="$RUNNER_TEMP/notary-key.p8" + printf '%s' "$NOTARY_KEY_P8" > "$NOTARY_KEY_FILE" + # The secret is the raw PEM text of the downloaded AuthKey .p8 + # (NOT base64-wrapped, unlike the p12 secret) -- fail here, with a + # clear reason, rather than at the end of a 40-minute build. + grep -q "BEGIN PRIVATE KEY" "$NOTARY_KEY_FILE" || { echo "APPSTORE_CONNECT_API_KEY_P8 does not look like a raw PEM .p8 key"; exit 1; } + echo "NOTARY_KEY_FILE=$NOTARY_KEY_FILE" >> "$GITHUB_ENV" + - name: Build, sign, and verify the app + # package_app.sh signs to notarization requirements with the + # identity exported above; test_packaged_bridge.sh then executes the + # hardened-runtime bundle (bridge smoke on the signed app). + working-directory: app/ScanStudio + run: make package + - name: Notarize and staple the app + # The .app needs its own ticket: the in-app updater's + # publisher-trust gate requires a stapled ticket on the INSTALLED + # app, and an app copied out of a DMG does not inherit the DMG's + # ticket (it also covers offline first launch). + env: + NOTARY_KEY_ID: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_API_ISSUER_ID }} + working-directory: app/ScanStudio + run: | + test -n "$NOTARY_KEY_ID" || { echo "APPSTORE_CONNECT_API_KEY_ID secret is missing or empty"; exit 1; } + test -n "$NOTARY_ISSUER_ID" || { echo "APPSTORE_CONNECT_API_ISSUER_ID secret is missing or empty"; exit 1; } + zsh scripts/notarize_artifact.sh app .build/ScanStudio.app - name: Build and verify the local DMG # package_dmg.sh names the artifact ScanStudio--macOS-.dmg # (arch from uname -m, i.e. this runner's native arch) and refuses to - # overwrite an existing artifact. It also verifies the mounted app's - # code signature and runs test_packaged_bridge.sh on it; any failure - # here aborts the job and nothing is released. With - # SCANSTUDIO_SIGNING_IDENTITY exported above, package_app.sh signs to - # notarization requirements instead of ad-hoc. + # overwrite an existing artifact; it builds from the already + # notarized-and-stapled app above. working-directory: app/ScanStudio - run: make dmg + run: ./scripts/package_dmg.sh - name: Notarize and staple the DMG # MUST run before SHA256SUMS/latest.json are emitted: stapling # mutates the DMG, so every published checksum has to be computed # from the stapled artifact. env: - NOTARY_KEY_B64: ${{ secrets.APPSTORE_CONNECT_API_KEY_P8 }} NOTARY_KEY_ID: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }} NOTARY_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_API_ISSUER_ID }} working-directory: app/ScanStudio run: | - test -n "$NOTARY_KEY_B64" || { echo "APPSTORE_CONNECT_API_KEY_P8 secret is missing or empty"; exit 1; } - test -n "$NOTARY_KEY_ID" || { echo "APPSTORE_CONNECT_API_KEY_ID secret is missing or empty"; exit 1; } - test -n "$NOTARY_ISSUER_ID" || { echo "APPSTORE_CONNECT_API_ISSUER_ID secret is missing or empty"; exit 1; } - export NOTARY_KEY_FILE="$RUNNER_TEMP/notary-key.p8" - printf '%s' "$NOTARY_KEY_B64" > "$NOTARY_KEY_FILE" - zsh scripts/notarize_dmg.sh ".build/ScanStudio-$RELEASE_VERSION-macOS-$(uname -m).dmg" - rm -f "$NOTARY_KEY_FILE" + zsh scripts/notarize_artifact.sh dmg ".build/ScanStudio-$RELEASE_VERSION-macOS-$(uname -m).dmg" - name: Emit SHA256SUMS and arch-keyed latest.json working-directory: app/ScanStudio run: | @@ -192,9 +216,10 @@ jobs: app/ScanStudio/.build/latest.json if-no-files-found: error retention-days: 1 - - name: Delete the throwaway signing keychain + - name: Delete the throwaway signing keychain and key material if: always() run: | + rm -f "$RUNNER_TEMP/signing.p12" "$RUNNER_TEMP/notary-key.p8" KEYCHAIN="$RUNNER_TEMP/scanstudio-signing.keychain-db" if [ -f "$KEYCHAIN" ]; then security list-keychains -d user -s login.keychain-db diff --git a/.github/workflows/signing-dry-run.yml b/.github/workflows/signing-dry-run.yml new file mode 100644 index 0000000..5eb09a3 --- /dev/null +++ b/.github/workflows/signing-dry-run.yml @@ -0,0 +1,128 @@ +name: Signing dry run + +# Manually triggered rehearsal of the ENTIRE Developer ID signing + +# notarization + stapling path on one arch, without tagging or publishing +# anything. The release workflow's signing steps only ever run on a real +# tag under fail-fast, so this is the only way to exercise the path -- and +# the runtime checks the notary service cannot make (is the entitlement on +# the right binary, does the hardened interpreter's ctypes dlopen actually +# work under library validation) -- before a release depends on it. + +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + dry-run: + name: Sign, notarize, staple, and runtime-check (arm64) + runs-on: macos-15 + timeout-minutes: 150 + env: + UV_PYTHON_PREFERENCE: only-managed + UV_PYTHON_CPYTHON_BUILD: "20260718" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable 2026-08-05 + with: + toolchain: '1.97.1' + - name: Install exact uv and managed Python build toolchain + run: | + python3 -I -S -B scripts/install_pinned_uv_python.py \ + --install-root "$RUNNER_TEMP/scanstudio-uv-python" + - name: Verify exact Rust toolchain + run: python3 -I -S -B scripts/verify_pinned_rust.py + - name: Install locked production bridge dependencies + working-directory: bridge + run: uv sync --locked --no-dev --no-install-package python-sane + - name: Import the Developer ID signing identity + env: + P12_B64: ${{ secrets.MACOS_SIGNING_CERT_P12_BASE64 }} + CERT_PW: ${{ secrets.MACOS_SIGNING_CERT_PASSWORD }} + run: | + test -n "$P12_B64" || { echo "MACOS_SIGNING_CERT_P12_BASE64 secret is missing or empty"; exit 1; } + test -n "$CERT_PW" || { echo "MACOS_SIGNING_CERT_PASSWORD secret is missing or empty"; exit 1; } + KEYCHAIN="$RUNNER_TEMP/scanstudio-signing.keychain-db" + KEYCHAIN_PW="$(uuidgen)" + security create-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PW" "$KEYCHAIN" + printf '%s' "$P12_B64" | base64 -d > "$RUNNER_TEMP/signing.p12" + security import "$RUNNER_TEMP/signing.p12" -k "$KEYCHAIN" -P "$CERT_PW" -T /usr/bin/codesign + rm -f "$RUNNER_TEMP/signing.p12" + security set-key-partition-list -S 'apple-tool:,apple:' -s -k "$KEYCHAIN_PW" "$KEYCHAIN" > /dev/null + security list-keychains -d user -s "$KEYCHAIN" login.keychain-db + IDENTITY="$(security find-identity -v -p codesigning "$KEYCHAIN" | awk -F'"' '/Developer ID Application/ {print $2; exit}')" + test -n "$IDENTITY" || { echo "no Developer ID Application identity found in the imported certificate"; exit 1; } + echo "SCANSTUDIO_SIGNING_IDENTITY=$IDENTITY" >> "$GITHUB_ENV" + echo "Imported signing identity: $IDENTITY" + - name: Write the notarization API key + env: + NOTARY_KEY_P8: ${{ secrets.APPSTORE_CONNECT_API_KEY_P8 }} + run: | + test -n "$NOTARY_KEY_P8" || { echo "APPSTORE_CONNECT_API_KEY_P8 secret is missing or empty"; exit 1; } + NOTARY_KEY_FILE="$RUNNER_TEMP/notary-key.p8" + printf '%s' "$NOTARY_KEY_P8" > "$NOTARY_KEY_FILE" + grep -q "BEGIN PRIVATE KEY" "$NOTARY_KEY_FILE" || { echo "APPSTORE_CONNECT_API_KEY_P8 does not look like a raw PEM .p8 key"; exit 1; } + echo "NOTARY_KEY_FILE=$NOTARY_KEY_FILE" >> "$GITHUB_ENV" + - name: Build, sign, and verify the app + working-directory: app/ScanStudio + env: + SCANSTUDIO_RELEASE_VERSION: 0.0.0-signing-dry-run + run: make package + - name: Assert the entitlement landed on the bridge interpreter only + working-directory: app/ScanStudio + run: | + PYBIN="$(/usr/bin/find .build/ScanStudio.app/Contents/Resources -type f -name 'python3.13' -path '*/bin/*' | head -1)" + test -n "$PYBIN" || { echo "bridge interpreter not found in the bundle"; exit 1; } + codesign -d --entitlements - "$PYBIN" 2>/dev/null | grep -q "disable-library-validation" \ + || { echo "bridge interpreter is missing the disable-library-validation entitlement"; exit 1; } + codesign -d --entitlements - .build/ScanStudio.app/Contents/MacOS/scanstudio-engine 2>/dev/null | grep -q "disable-library-validation" \ + && { echo "engine unexpectedly carries the entitlement"; exit 1; } + echo "entitlement scoped correctly" + - name: Notarize and staple the app + env: + NOTARY_KEY_ID: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_API_ISSUER_ID }} + working-directory: app/ScanStudio + run: | + test -n "$NOTARY_KEY_ID" || { echo "APPSTORE_CONNECT_API_KEY_ID secret is missing or empty"; exit 1; } + test -n "$NOTARY_ISSUER_ID" || { echo "APPSTORE_CONNECT_API_ISSUER_ID secret is missing or empty"; exit 1; } + zsh scripts/notarize_artifact.sh app .build/ScanStudio.app + - name: Prove the hardened interpreter can dlopen the bundled libusb + # The check the notary service cannot make: under hardened runtime + + # library validation, the entitled interpreter must still be able to + # ctypes-load the bundled dylib (the live capture path's first step). + working-directory: app/ScanStudio + run: | + PYBIN="$(/usr/bin/find .build/ScanStudio.app/Contents/Resources -type f -name 'python3.13' -path '*/bin/*' | head -1)" + "$PYBIN" -I -c "import ctypes; ctypes.CDLL('.build/ScanStudio.app/Contents/Frameworks/coolscanpy/_native/libusb-1.0.dylib'); print('dlopen ok under hardened runtime')" + - name: Build the DMG from the stapled app + working-directory: app/ScanStudio + run: ./scripts/package_dmg.sh + - name: Notarize and staple the DMG + env: + NOTARY_KEY_ID: ${{ secrets.APPSTORE_CONNECT_API_KEY_ID }} + NOTARY_ISSUER_ID: ${{ secrets.APPSTORE_CONNECT_API_ISSUER_ID }} + working-directory: app/ScanStudio + run: | + zsh scripts/notarize_artifact.sh dmg .build/ScanStudio-*-macOS-$(uname -m).dmg + - name: Upload the dry-run DMG for manual inspection + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: signing-dry-run-dmg + path: app/ScanStudio/.build/ScanStudio-*-macOS-*.dmg + if-no-files-found: error + retention-days: 3 + - name: Delete the throwaway signing keychain and key material + if: always() + run: | + rm -f "$RUNNER_TEMP/signing.p12" "$RUNNER_TEMP/notary-key.p8" + KEYCHAIN="$RUNNER_TEMP/scanstudio-signing.keychain-db" + if [ -f "$KEYCHAIN" ]; then + security list-keychains -d user -s login.keychain-db + security delete-keychain "$KEYCHAIN" + fi diff --git a/app/ScanStudio/packaging/Info.plist b/app/ScanStudio/packaging/Info.plist index b3d3c0d..51594d7 100644 --- a/app/ScanStudio/packaging/Info.plist +++ b/app/ScanStudio/packaging/Info.plist @@ -20,6 +20,8 @@ 14 ScanStudioRelease + ScanStudioUpdateTeamIdentifier + L95G5TS6AK LSMinimumSystemVersion 14.0 NSHighResolutionCapable diff --git a/app/ScanStudio/scripts/notarize_artifact.sh b/app/ScanStudio/scripts/notarize_artifact.sh new file mode 100755 index 0000000..a22086b --- /dev/null +++ b/app/ScanStudio/scripts/notarize_artifact.sh @@ -0,0 +1,82 @@ +#!/bin/zsh +set -euo pipefail + +# Notarizes and staples ONE release artifact -- the .app first, then the +# .dmg built from the stapled app. Both need their own ticket: the in-app +# updater's publisher-trust gate requires a stapled ticket on the INSTALLED +# app (UpdateService reads kSecCodeInfoStapledNotarizationTicket), and an +# app copied out of a DMG does not inherit the DMG's ticket; the DMG's own +# ticket covers the first-open Gatekeeper check of the download itself, +# including offline. +# +# Runs only in release automation, and for the DMG strictly BEFORE +# SHA256SUMS/latest.json are emitted -- stapling mutates the file, so every +# published checksum must be computed after this script succeeds. +# +# Usage: notarize_artifact.sh app +# notarize_artifact.sh dmg +# +# Required environment: +# SCANSTUDIO_SIGNING_IDENTITY Developer ID Application identity string +# NOTARY_KEY_FILE path to the App Store Connect API .p8 key +# NOTARY_KEY_ID the key id for that key +# NOTARY_ISSUER_ID the issuer id for the team + +kind="${1:?usage: notarize_artifact.sh }" +artifact="${2:?usage: notarize_artifact.sh }" +[[ -e "$artifact" ]] || { print -u2 "no such artifact: $artifact"; exit 1; } +: "${SCANSTUDIO_SIGNING_IDENTITY:?}" "${NOTARY_KEY_FILE:?}" "${NOTARY_KEY_ID:?}" "${NOTARY_ISSUER_ID:?}" + +submit_and_require_accepted() { + local upload="$1" + local submission submission_id + # `|| true`: notarytool's exit code differs across Xcode versions and a + # transport/auth failure must still leave its output in the log -- the + # status line in the captured output is the single authority either way. + submission="$(xcrun notarytool submit "$upload" \ + --key "$NOTARY_KEY_FILE" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER_ID" \ + --wait --timeout 45m 2>&1)" || true + print -r -- "$submission" + if [[ "$submission" != *"status: Accepted"* ]]; then + submission_id="$(print -r -- "$submission" | awk '/^[[:space:]]*id: /{print $2; exit}')" + if [[ -n "$submission_id" ]]; then + # The per-file rejection reasons live only in this log. + xcrun notarytool log "$submission_id" \ + --key "$NOTARY_KEY_FILE" \ + --key-id "$NOTARY_KEY_ID" \ + --issuer "$NOTARY_ISSUER_ID" || true + fi + print -u2 "notarization was not accepted for $upload" + exit 1 + fi +} + +case "$kind" in + app) + # Apps are submitted as a zip; the ticket staples onto the bundle. + upload_dir="$(mktemp -d)" + trap 'rm -rf "$upload_dir"' EXIT + upload_zip="$upload_dir/${artifact:t}.zip" + ditto -c -k --keepParent "$artifact" "$upload_zip" + submit_and_require_accepted "$upload_zip" + xcrun stapler staple "$artifact" + xcrun stapler validate "$artifact" + ;; + dmg) + codesign --force --sign "$SCANSTUDIO_SIGNING_IDENTITY" --timestamp "$artifact" + submit_and_require_accepted "$artifact" + xcrun stapler staple "$artifact" + xcrun stapler validate "$artifact" + # Gatekeeper's own verdict on the stapled DMG -- the check a user's + # machine effectively runs on first open. + spctl -a -t open --context context:primary-signature -v "$artifact" + ;; + *) + print -u2 "unknown artifact kind: $kind (expected app or dmg)" + exit 1 + ;; +esac + +print "Notarized and stapled $artifact" diff --git a/app/ScanStudio/scripts/notarize_dmg.sh b/app/ScanStudio/scripts/notarize_dmg.sh deleted file mode 100755 index f26eedb..0000000 --- a/app/ScanStudio/scripts/notarize_dmg.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/zsh -set -euo pipefail - -# Signs, notarizes, and staples one release DMG, then proves Gatekeeper -# accepts it. Runs only in the release workflow, after package_dmg.sh built -# the DMG from the Developer-ID-signed app and BEFORE SHA256SUMS/latest.json -# are emitted -- stapling mutates the DMG, so every checksum must be -# computed after this script succeeds. -# -# Required environment: -# SCANSTUDIO_SIGNING_IDENTITY Developer ID Application identity string -# NOTARY_KEY_FILE path to the App Store Connect API .p8 key -# NOTARY_KEY_ID the key id for that key -# NOTARY_ISSUER_ID the issuer id for the team - -dmg="${1:?usage: notarize_dmg.sh }" -[[ -f "$dmg" ]] || { print -u2 "no such DMG: $dmg"; exit 1 } -: "${SCANSTUDIO_SIGNING_IDENTITY:?}" "${NOTARY_KEY_FILE:?}" "${NOTARY_KEY_ID:?}" "${NOTARY_ISSUER_ID:?}" - -codesign --force --sign "$SCANSTUDIO_SIGNING_IDENTITY" --timestamp "$dmg" - -# notarytool can exit 0 while reporting a rejected submission, so the status -# line in its output is the authority; on anything but Accepted, fetch the -# per-file log (the only place the actual rejection reasons live) and fail. -submission="$(xcrun notarytool submit "$dmg" \ - --key "$NOTARY_KEY_FILE" \ - --key-id "$NOTARY_KEY_ID" \ - --issuer "$NOTARY_ISSUER_ID" \ - --wait --timeout 45m 2>&1)" -print -r -- "$submission" -if [[ "$submission" != *"status: Accepted"* ]]; then - submission_id="$(print -r -- "$submission" | awk '/^[[:space:]]*id: /{print $2; exit}')" - if [[ -n "$submission_id" ]]; then - xcrun notarytool log "$submission_id" \ - --key "$NOTARY_KEY_FILE" \ - --key-id "$NOTARY_KEY_ID" \ - --issuer "$NOTARY_ISSUER_ID" || true - fi - print -u2 "notarization was not accepted" - exit 1 -fi - -xcrun stapler staple "$dmg" -# Gatekeeper's own verdict on the stapled DMG -- the check a user's machine -# will effectively run on first open. -spctl -a -t open --context context:primary-signature -v "$dmg" -print "Notarized and stapled $dmg" diff --git a/app/ScanStudio/scripts/package_app.sh b/app/ScanStudio/scripts/package_app.sh index 83f306f..f26820a 100755 --- a/app/ScanStudio/scripts/package_app.sh +++ b/app/ScanStudio/scripts/package_app.sh @@ -636,9 +636,11 @@ strip -S "$staged_app/Contents/MacOS/scanstudio-engine" "$staged_app/Contents/Ma # the bundle signed individually with a secure timestamp (notarytool rejects # any unsigned Mach-O, and --deep never descends into Resources, where the # whole BridgeRuntime CPython tree lives), hardened runtime on every -# executable, and executables carrying the one entitlement the bridge's -# ctypes loader needs to dlopen the bundled (same-team-signed) libusb under -# library validation. +# executable, and executables carrying disable-library-validation. That +# entitlement exists for the HOST libraries the bridge's optional paths +# ctypes-load -- a Homebrew libsane found via find_library is signed by a +# foreign team, exactly what library validation blocks. (The bundled libusb +# is same-team-signed and would pass validation on its own.) signing_identity="${SCANSTUDIO_SIGNING_IDENTITY:--}" timestamp_flags=() runtime_flags=() @@ -666,7 +668,14 @@ PLIST codesign --force --sign "$signing_identity" --timestamp "$candidate" fi done < <(find "$staged_app/Contents/Resources" "$staged_app/Contents/Frameworks" \ - -type f -print0 2>/dev/null) + -type f \( -perm -u+x -o -name '*.so' -o -name '*.dylib' \) -print0 2>/dev/null) + # The predicate above is a fork-saving pre-filter only (this tree holds + # thousands of plain-text files); file(1) remains the authority on what + # is actually a Mach-O. Every Mach-O in the uv-managed CPython layout is + # executable-bit or *.so/*.dylib. Note for provenance audits: this sweep + # re-signs files whose hashes were pinned earlier in this script (e.g. + # the python-sane extension), so release artifacts intentionally differ + # from those pre-signing pins. fi codesign --force --sign "$signing_identity" "${timestamp_flags[@]}" "$bundled_libusb" codesign --verify --strict "$bundled_libusb" diff --git a/app/ScanStudio/scripts/package_dmg.sh b/app/ScanStudio/scripts/package_dmg.sh index 5f86e9f..070c1e8 100755 --- a/app/ScanStudio/scripts/package_dmg.sh +++ b/app/ScanStudio/scripts/package_dmg.sh @@ -83,4 +83,7 @@ mounted=0 mv "$temporary_dmg" "$output" digest="$(shasum -a 256 "$output" | awk '{print $1}')" print "Packaged $output" -print "SHA-256 $digest" +# Local digest of the freshly built DMG. In the release pipeline +# notarization stapling mutates the file AFTER this point, so the published +# SHA256SUMS value will differ from this line -- it is informational only. +print "SHA-256 (pre-staple) $digest" diff --git a/docs/AUTO-UPDATE.md b/docs/AUTO-UPDATE.md index be271a1..4341674 100644 --- a/docs/AUTO-UPDATE.md +++ b/docs/AUTO-UPDATE.md @@ -9,11 +9,14 @@ ## Why two paths -ScanStudio is ad-hoc signed today (`codesign -dv` → `Signature=adhoc`, no -TeamIdentifier). Neither the custom updater nor Sparkle may install a release -from that trust state. A checksum supplied by the release server is an -integrity check, not an independent publisher identity. Path A can check and -download today, but installation fails closed until the running app and update +Release builds are Developer-ID signed, notarized, and stapled as of the +signing lane (release.yml's DMG job signs the app and DMG, notarizes both, +and staples both; local/PR-CI builds stay ad-hoc — `codesign -dv` → +`Signature=adhoc`). The packaged Info.plist stamps +`ScanStudioUpdateTeamIdentifier`. A checksum supplied by the release server +is an integrity check, not an independent publisher identity. Path A can +check and download, and with the signing lane in place the running app and +update are Developer ID signed, securely timestamped, notarized, and stapled. ## Path A — how it works and its publisher gate @@ -108,12 +111,13 @@ imported into your keychain. the feed's base data (see reuse map below). 5. **Decide and document repo-secret names before wiring CI** — settle the secret surface only after the cert + notary + key all exist. - **Why:** CI wiring is the last step and should reference already-decided - names (`DEVELOPER_ID_CERT`, `DEVELOPER_ID_TEAM`, `NOTARY_API_KEY`, - `NOTARY_KEY_ID`, `NOTARY_KEY_ISSUER_ID`, `SPARKLE_EDDSA_KEY`, …) so the - appcast step is a one-shot edit to `release.yml`. **Where:** repository - Settings → Secrets and variables → Actions; record the chosen names in the - future Sparkle plan. + **Why:** CI wiring should reference already-decided names. DECIDED and + live in the signing lane: `MACOS_SIGNING_CERT_P12_BASE64`, + `MACOS_SIGNING_CERT_PASSWORD`, `APPSTORE_CONNECT_API_KEY_P8` (raw PEM, + not base64), `APPSTORE_CONNECT_API_KEY_ID`, + `APPSTORE_CONNECT_API_ISSUER_ID`. A Sparkle path would add only + `SPARKLE_EDDSA_KEY`. **Where:** repository Settings → Secrets and + variables → Actions. ## Reuse map @@ -127,9 +131,10 @@ What Path B reuses from Path A, and what Path B adds. | `UpdateDownloader` (01-04, planned) | Already validates hash + code signature; can gate what Sparkle is offered as a candidate | | `UpdateSettingsView` / `UpdateFlowModel` (01-05, planned) | Unchanged — they keep presenting version/install/rollback, just fed by Sparkle instead of Path A | -**What Sparkle ADDS (none exist today):** the `SUFeedURL` key in the packaged -`Info.plist`, EdDSA feed signatures, Developer ID signing, notarization + -staple, and an appcast publish step in CI. +**What Sparkle ADDS (beyond the signing lane, which already provides +Developer ID signing, notarization, and stapling on releases):** the +`SUFeedURL` key in the packaged `Info.plist`, EdDSA feed signatures, and an +appcast publish step in CI. **What would change when the gate opens:** `app/ScanStudio/packaging/Info.plist` (SUFeedURL), `app/ScanStudio/scripts/package_app.sh` (sign `-` → Developer ID