From 43a59aa6a598d55cfef45610f4ac5ee4619049e4 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Wed, 4 Nov 2026 15:31:07 -0500 Subject: [PATCH] feat(gpui): sign and notarize the macOS images with a Developer ID With Voltage's Developer ID in the repository secrets, the release workflow signs Buzz.app for notarization and then notarizes the .dmg. The signing uses the hardened runtime, secure timestamps, and entitlements for the microphone, GStreamer's ORC and the bundled Node's JIT. The workflow then staples the ticket and checks that Gatekeeper accepts the app as notarized. Without the secrets, packaging signs ad hoc as before, and a release run warns. check-macos-dmg.sh runs a hardened build's self-check as signed. dyld ignores DYLD_ variables under the hardened runtime, so the library trace runs on a copy signed ad hoc. Refs #87 Signed-off-by: Tee8z --- .../workflows/voltage-buzz-gpui-release.yml | 48 +++++++++++++++++++ apps/buzz-gpui/README.md | 29 +++++++++-- .../packaging/macos/buzz-gpui.entitlements | 14 ++++++ .../packaging/macos/node.entitlements | 17 +++++++ apps/buzz-gpui/scripts/check-macos-dmg.sh | 42 +++++++++++----- apps/buzz-gpui/scripts/notarize-macos.sh | 43 +++++++++++++++++ apps/buzz-gpui/scripts/package-macos.sh | 43 ++++++++++++----- 7 files changed, 206 insertions(+), 30 deletions(-) create mode 100644 apps/buzz-gpui/packaging/macos/buzz-gpui.entitlements create mode 100644 apps/buzz-gpui/packaging/macos/node.entitlements create mode 100755 apps/buzz-gpui/scripts/notarize-macos.sh diff --git a/.github/workflows/voltage-buzz-gpui-release.yml b/.github/workflows/voltage-buzz-gpui-release.yml index 96a9fc0714d..53fad607d82 100644 --- a/.github/workflows/voltage-buzz-gpui-release.yml +++ b/.github/workflows/voltage-buzz-gpui-release.yml @@ -23,6 +23,8 @@ on: - apps/buzz-gpui/scripts/test-package-build.py - apps/buzz-gpui/scripts/bundle-macos-* - apps/buzz-gpui/scripts/check-macos-* + - apps/buzz-gpui/scripts/notarize-macos.sh + - apps/buzz-gpui/packaging/macos/** concurrency: group: buzz-gpui-release-${{ github.ref }} @@ -134,10 +136,56 @@ jobs: save-if: ${{ github.ref == 'refs/heads/main' }} # Keep the release build when packaging fails after it. cache-on-failure: true + # With Voltage's Developer ID in the repository's secrets, the app is + # signed for notarization; without it (pull requests from forks get no + # secrets), ad hoc. + - name: Developer ID keychain + id: signing + env: + P12: ${{ secrets.MACOS_DEVELOPER_ID_P12 }} + P12_PASSWORD: ${{ secrets.MACOS_DEVELOPER_ID_P12_PASSWORD }} + EVENT: ${{ github.event_name }} + run: | + if [ -z "$P12" ]; then + if [ "$EVENT" = workflow_dispatch ]; then + echo "::warning::No Developer ID: this release's macOS images are signed ad hoc and not notarized." + fi + exit 0 + fi + keychain="$RUNNER_TEMP/developer-id.keychain-db" + password="$(openssl rand -hex 16)" + security create-keychain -p "$password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$password" "$keychain" + printf '%s' "$P12" | base64 --decode >"$RUNNER_TEMP/developer-id.p12" + security import "$RUNNER_TEMP/developer-id.p12" -k "$keychain" \ + -P "$P12_PASSWORD" -T /usr/bin/codesign + rm "$RUNNER_TEMP/developer-id.p12" + # codesign may use the key without a prompt. + security set-key-partition-list -S apple-tool:,apple: -s -k "$password" "$keychain" >/dev/null + security list-keychains -d user -s "$keychain" login.keychain-db + identity="$(security find-identity -v -p codesigning "$keychain" \ + | awk '/"Developer ID Application: / { print $2; exit }')" + if [ -z "$identity" ]; then + echo "::error::MACOS_DEVELOPER_ID_P12 holds no Developer ID Application identity." + exit 1 + fi + echo "MACOS_SIGN_IDENTITY=$identity" >>"$GITHUB_ENV" + echo "signed=true" >>"$GITHUB_OUTPUT" - run: scripts/package-macos.sh # The bundle loads only what it carries, and plays media with the # GStreamer framework moved away. - run: scripts/check-macos-dmg.sh dist/*.dmg + - name: Notarize + if: steps.signing.outputs.signed == 'true' + env: + APPLE_API_KEY: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: | + export APPLE_API_KEY_PATH="$RUNNER_TEMP/notary-key.p8" + printf '%s\n' "$APPLE_API_KEY" >"$APPLE_API_KEY_PATH" + scripts/notarize-macos.sh dist/*.dmg - run: ls -l dist/*.dmg - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/apps/buzz-gpui/README.md b/apps/buzz-gpui/README.md index 78506feb307..7a90b695e80 100644 --- a/apps/buzz-gpui/README.md +++ b/apps/buzz-gpui/README.md @@ -37,15 +37,16 @@ keyring, and they stop when the app quits. ## Laptop: install the app (macOS) -The macOS build is not signed or notarized. It carries the GStreamer -libraries and plugins it plays audio and video with, so nothing else has -to be installed. +The macOS build carries the GStreamer libraries and plugins it plays audio +and video with, so nothing else has to be installed. 1. Open `Buzz--macos-arm64.dmg` (Apple silicon) or `Buzz--macos-x86_64.dmg` (Intel). 2. Drag Buzz to Applications. -3. The first time, right-click Buzz in Applications and choose Open. If - macOS still refuses, run `xattr -dr com.apple.quarantine /Applications/Buzz.app`. +3. A build signed with a Developer ID and notarized opens like any other + app. A build signed ad hoc does not: the first time, right-click Buzz in + Applications and choose Open. If macOS still refuses, run + `xattr -dr com.apple.quarantine /Applications/Buzz.app`. Build the image on a Mac with `scripts/package-macos.sh`. It writes `dist/Buzz--macos-.dmg` for the Mac's own architecture. It @@ -58,6 +59,13 @@ The script copies the libraries and plugins the app uses into `Buzz.app`. from outside itself and the system, and plays media with the framework moved aside (it uses `sudo`). +The script signs ad hoc unless `MACOS_SIGN_IDENTITY` names a Developer ID +Application identity in the keychain. With one, it signs for notarization: +hardened runtime, secure timestamps, and the entitlements in +`packaging/macos`. `scripts/notarize-macos.sh ` then notarizes the +image with an App Store Connect API key, staples the ticket, and checks +that Gatekeeper accepts the app as notarized. + ## Laptop: install the app (Windows) The Windows build is not signed. It carries the GStreamer libraries and @@ -101,6 +109,17 @@ The Build workflow makes all of these packages. It runs by hand, for pull requests that change it, and for `v*` tags, which also attach the packages to a GitHub release. +Voltage's release workflow signs and notarizes the macOS images when the +repository has these secrets, and warns on a release without them: + +| Secret | Value | +|--------|-------| +| `MACOS_DEVELOPER_ID_P12` | The Developer ID Application certificate and its private key, as a base64 `.p12` | +| `MACOS_DEVELOPER_ID_P12_PASSWORD` | The `.p12` password | +| `APPLE_API_KEY_P8` | An App Store Connect API key (`.p8` contents) with the Developer role | +| `APPLE_API_KEY_ID` | That key's ID | +| `APPLE_API_ISSUER` | The issuer ID of the team's API keys | + ## Dev server: run `buzzd` (NixOS) Add the flake as an input and enable the module: diff --git a/apps/buzz-gpui/packaging/macos/buzz-gpui.entitlements b/apps/buzz-gpui/packaging/macos/buzz-gpui.entitlements new file mode 100644 index 00000000000..b8fa61bb908 --- /dev/null +++ b/apps/buzz-gpui/packaging/macos/buzz-gpui.entitlements @@ -0,0 +1,14 @@ + + + + + + + com.apple.security.device.audio-input + + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/apps/buzz-gpui/packaging/macos/node.entitlements b/apps/buzz-gpui/packaging/macos/node.entitlements new file mode 100644 index 00000000000..1ee18bffcf3 --- /dev/null +++ b/apps/buzz-gpui/packaging/macos/node.entitlements @@ -0,0 +1,17 @@ + + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-executable-page-protection + + + diff --git a/apps/buzz-gpui/scripts/check-macos-dmg.sh b/apps/buzz-gpui/scripts/check-macos-dmg.sh index fd313c52944..c08a4314cec 100755 --- a/apps/buzz-gpui/scripts/check-macos-dmg.sh +++ b/apps/buzz-gpui/scripts/check-macos-dmg.sh @@ -9,6 +9,8 @@ # uses, encodes a voice note and plays it and the test clips (WebM and # MP3 over HTTP, QuickTime from a file, whose index is at the end), while # dyld reports loading nothing from outside the bundle and the system. +# A Developer ID build passes the self-check as signed, under the +# hardened runtime, before the trace. # # For CI: it moves /Library/Frameworks/GStreamer.framework (with sudo) and # puts it back when done. @@ -91,22 +93,36 @@ for _ in $(seq 50); do sleep 0.2 done -# A clean environment, except for variables that would pull in the -# GStreamer moved aside if the app honoured them. log="$work/check.log" -status=0 mkdir "$work/home" elsewhere="$hidden/Versions/1.0/lib" -env -i HOME="$work/home" TMPDIR="$work" PATH=/usr/bin:/bin \ - GST_PLUGIN_PATH="$elsewhere/gstreamer-1.0" \ - GST_PLUGIN_SYSTEM_PATH="$elsewhere/gstreamer-1.0" \ - GST_PLUGIN_PATH_1_0="$elsewhere/gstreamer-1.0" \ - GIO_EXTRA_MODULES="$elsewhere/gio/modules" \ - DYLD_PRINT_LIBRARIES=1 \ - "$app/Contents/MacOS/buzz-gpui" --gst-check \ - "http://127.0.0.1:$port/clip.webm" \ - "http://127.0.0.1:$port/ping.mp3" \ - "file://$media/clip.mov" >"$log" 2>&1 || status=$? +# The self-check, in a clean environment except for variables that would +# pull in the GStreamer moved aside if the app honoured them, plus any given. +gst_check() { + env -i HOME="$work/home" TMPDIR="$work" PATH=/usr/bin:/bin \ + GST_PLUGIN_PATH="$elsewhere/gstreamer-1.0" \ + GST_PLUGIN_SYSTEM_PATH="$elsewhere/gstreamer-1.0" \ + GST_PLUGIN_PATH_1_0="$elsewhere/gstreamer-1.0" \ + GIO_EXTRA_MODULES="$elsewhere/gio/modules" \ + "$@" "$app/Contents/MacOS/buzz-gpui" --gst-check \ + "http://127.0.0.1:$port/clip.webm" \ + "http://127.0.0.1:$port/ping.mp3" \ + "file://$media/clip.mov" +} + +# A Developer ID build runs under the hardened runtime, where dyld ignores +# DYLD_ variables. It must play media as signed; the images it loads are then +# traced in this copy signed ad hoc, which loads the same ones. +if codesign --display --verbose=1 "$app" 2>&1 | grep -q '^CodeDirectory.*flags=.*runtime'; then + echo "== The signed app, under the hardened runtime" + gst_check || { echo "the self-check exited with $?"; exit 1; } + for file in "$app"/Contents/MacOS/*; do + codesign --force --sign - "$file" 2>/dev/null + done + echo "== The same app, signed ad hoc so that dyld reports what it loads" +fi +status=0 +gst_check DYLD_PRINT_LIBRARIES=1 >"$log" 2>&1 || status=$? grep -v '^dyld\[' "$log" [ "$status" = 0 ] || { echo "the self-check exited with $status"; exit 1; } diff --git a/apps/buzz-gpui/scripts/notarize-macos.sh b/apps/buzz-gpui/scripts/notarize-macos.sh new file mode 100755 index 00000000000..88d2ad028ab --- /dev/null +++ b/apps/buzz-gpui/scripts/notarize-macos.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# Notarizes a .dmg that scripts/package-macos.sh signed with a Developer ID, +# staples the ticket to it, and checks that Gatekeeper accepts the image and +# the app inside as notarized, so it opens on another Mac with no warning. +# +# Authenticates with an App Store Connect API key: +# APPLE_API_KEY_PATH the .p8 key file +# APPLE_API_KEY_ID its key ID +# APPLE_API_ISSUER its issuer ID +# +# Usage: scripts/notarize-macos.sh dist/Buzz--macos-.dmg +set -euo pipefail +dmg="$1" +work="$(mktemp -d)" +mount="$work/mount" +cleanup() { + hdiutil detach "$mount" >/dev/null 2>&1 || true + rm -rf "$work" +} +trap cleanup EXIT + +credentials=(--key "$APPLE_API_KEY_PATH" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER") +# notarytool exits 0 for a rejected submission too; the status says which. +xcrun notarytool submit "$dmg" "${credentials[@]}" --wait --timeout 1h \ + --output-format json >"$work/submission.json" +cat "$work/submission.json" +read -r id status < <(python3 -c ' +import json, sys +s = json.load(open(sys.argv[1])) +print(s["id"], s["status"])' "$work/submission.json") +if [ "$status" != Accepted ]; then + echo "Notarization ended $status. Apple's log:" >&2 + xcrun notarytool log "$id" "${credentials[@]}" >&2 || true + exit 1 +fi + +xcrun stapler staple "$dmg" +xcrun stapler validate "$dmg" +spctl --assess --type open --context context:primary-signature --verbose=2 "$dmg" +mkdir "$mount" +hdiutil attach -nobrowse -readonly -mountpoint "$mount" "$dmg" >/dev/null +spctl --assess --type execute --verbose=2 "$mount/Buzz.app" 2>&1 | tee "$work/assess" +grep -q 'source=Notarized Developer ID' "$work/assess" diff --git a/apps/buzz-gpui/scripts/package-macos.sh b/apps/buzz-gpui/scripts/package-macos.sh index 353046b14dc..8079e15b5ea 100755 --- a/apps/buzz-gpui/scripts/package-macos.sh +++ b/apps/buzz-gpui/scripts/package-macos.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Builds an ad-hoc signed Buzz.app and wraps it in a .dmg. Runs on macOS, +# Builds a signed Buzz.app and wraps it in a .dmg. Runs on macOS, # for the architecture of the machine it runs on. The app carries the # GStreamer it plays media with, copied from the official GStreamer # framework (the runtime and development .pkg installers from @@ -10,6 +10,12 @@ # # Usage: scripts/package-macos.sh -> dist/Buzz--macos-.dmg # GSTREAMER_PREFIX overrides where the framework is. +# +# MACOS_SIGN_IDENTITY, when set, is a Developer ID Application identity in +# the keychain (its name or SHA-1). The app and image are then signed with it +# for notarization (scripts/notarize-macos.sh): hardened runtime, a secure +# timestamp, and the entitlements in packaging/macos. Otherwise the +# signature is ad hoc. set -euo pipefail node_version=22.23.3 node_sha256_arm64=23b25245dcfb9af7262f8ff142e9e2e0af025368117329e7a7458a51e5922f53 @@ -107,22 +113,32 @@ for manifest in "$work"/plugins/*/buzz-plugin.json; do done rm -rf "$work/node" "$work/plugins" "$node_tarball" -# Ad-hoc signature: Apple Silicon refuses to run unsigned code at all, and -# install_name_tool broke the signatures the copies came with. Each -# library, plugin and helper first, then the app around them (signing the -# main executable signs the app). +# Apple Silicon refuses to run unsigned code at all, and install_name_tool +# broke the signatures the copies came with. Each library, plugin and helper +# first, then the app around them (signing the bundle signs the main +# executable). +identity="${MACOS_SIGN_IDENTITY:--}" +sign() { + local options=(--force --sign "$identity") + if [ "$identity" != - ]; then + options+=(--options runtime --timestamp) + fi + # Quiet about the signatures it replaces, loud about failures. + if ! out="$(codesign "${options[@]}" "$@" 2>&1)"; then + echo "$out" >&2 + exit 1 + fi +} find "$app/Contents" -type f ! -path "$app/Contents/MacOS/buzz-gpui" \ \( -name '*.dylib' -o -name '*.so' -o -perm -u+x \) -print0 \ | while IFS= read -r -d '' file; do - if file -b "$file" | grep -q Mach-O; then - # Quiet about the signatures it replaces, loud about failures. - if ! out="$(codesign --force --sign - "$file" 2>&1)"; then - echo "$out" >&2 - exit 1 - fi + if [ "$file" = "$app/Contents/Resources/node/bin/node" ]; then + sign --entitlements packaging/macos/node.entitlements "$file" + elif file -b "$file" | grep -q Mach-O; then + sign "$file" fi done -codesign --force --deep --sign - "$app" +sign --entitlements packaging/macos/buzz-gpui.entitlements "$app" codesign --verify --deep --strict "$app" dmg="dist/Buzz-$version-macos-$arch.dmg" @@ -134,5 +150,8 @@ rm -rf "$iconset" size_mb="$(du -sm "$work" | cut -f1)" hdiutil create -volname Buzz -srcfolder "$work" -size "$((size_mb * 5 / 4 + 20))m" \ -ov -format UDZO "$dmg" +if [ "$identity" != - ]; then + codesign --sign "$identity" --timestamp "$dmg" +fi rm -rf "$work" ls -la "$dmg"