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"