From 82e5de126137176da580102ddd7f7f9df0c614f4 Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 13 Oct 2026 22:04:12 +0200 Subject: [PATCH] macOS: sign jfjoch_viewer and rugnux with a Developer ID and notarize them JFJOCH_MACOS_SIGNING_IDENTITY (the certificate's SHA-1) makes the install step sign the shipped program with hardened runtime and secure timestamp: macdeployqt's -sign-for-notarization for the viewer bundle, a codesign INSTALL(CODE) for rugnux. tools/notarize_macos.sh then submits the cpack artefact to Apple, waits for the verdict and staples the ticket to the .dmg; the rugnux binary inside the .tgz is submitted as a zip, as a bare executable cannot carry a ticket. Both macOS CI jobs resolve the identity from the runner's keychain, check for the hardened runtime and notarize on every push, so a rejection shows up with the change that caused it. The identity is passed as the hash, not the name: Qt's generated deploy script writes DEPLOY_TOOL_OPTIONS unquoted, so a name with spaces splits into several macdeployqt arguments. The notarytool profile has to live in the login keychain file (store-credentials --keychain): the default data-protection keychain becomes unreadable once the screen locks, which an unattended runner's does. Verified end to end on the runner: both artefacts accepted by Apple, the stapled image and a quarantined copy of the app and of rugnux pass Gatekeeper as Notarized Developer ID, signing and the credential lookup work from a launchd-started process with the console locked. Co-Authored-By: Claude Fable 5.1 --- .gitea/workflows/build_and_test.yml | 42 +++++++++++++------- CMakeLists.txt | 9 +++++ docs/JFJOCH_VIEWER.md | 11 +++++- docs/RELEASE_CONTENTS.md | 15 ++------ docs/RUGNUX_INSTALL.md | 15 ++------ rugnux/CMakeLists.txt | 10 +++++ tools/notarize_macos.sh | 60 +++++++++++++++++++++++++++++ viewer/CMakeLists.txt | 11 +++++- 8 files changed, 135 insertions(+), 38 deletions(-) create mode 100755 tools/notarize_macos.sh diff --git a/.gitea/workflows/build_and_test.yml b/.gitea/workflows/build_and_test.yml index d90d00470..01b7ed1b3 100644 --- a/.gitea/workflows/build_and_test.yml +++ b/.gitea/workflows/build_and_test.yml @@ -244,11 +244,25 @@ jobs: - name: Clean previous build shell: bash run: rm -rf build-macos # host runners keep the workspace between runs + # The Developer ID in the runner's login keychain signs the app (hardened runtime, secure + # timestamp) so the .dmg can be notarized below; without it the build must not go on, as an + # ad-hoc signed download is refused by Gatekeeper. Passed on as the certificate's SHA-1 (see + # JFJOCH_MACOS_SIGNING_IDENTITY in CMakeLists.txt). The notarytool credentials sit in the same + # keychain under the profile jfjoch-notary (xcrun notarytool store-credentials --keychain + # ~/Library/Keychains/login.keychain-db: that file never locks here, the default data-protection + # keychain does with the screen). + - name: Signing identity + shell: bash + run: | + set -euo pipefail + identity=$(security find-identity -v -p codesigning | sed -n 's/^ *[0-9]*) \([0-9A-F]*\) "Developer ID Application: .*/\1/p' | head -1) + if [ -z "$identity" ]; then echo "no Developer ID Application identity in the runner's keychain"; exit 1; fi + echo "MACOS_SIGNING_IDENTITY=$identity" >> "$GITHUB_ENV" - name: Configure shell: bash # JFJOCH_VIEWER_ONLY is forced on macOS. No -mcpu: the arm64 baseline is what every Apple # Silicon Mac runs, and NEON is part of it. - run: cmake -S . -B build-macos -DCMAKE_BUILD_TYPE=Release -DJFJOCH_USE_CUDA=OFF -DCMAKE_PREFIX_PATH="$QT_PREFIX" + run: cmake -S . -B build-macos -DCMAKE_BUILD_TYPE=Release -DJFJOCH_USE_CUDA=OFF -DCMAKE_PREFIX_PATH="$QT_PREFIX" -DJFJOCH_MACOS_SIGNING_IDENTITY="$MACOS_SIGNING_IDENTITY" # The quick cross-platform checks (tests/PortableTest.cpp and the [portable] cases), before # anything is packaged. - name: Portable tests @@ -274,11 +288,13 @@ jobs: # ... and nothing in the bundle may point at this machine's Qt (or anything else under /Users) find "$app" -type f \( -perm +111 -o -name '*.dylib' \) -exec otool -L {} + > /tmp/jfjv.libs 2>/dev/null || true if grep -q "/Users/" /tmp/jfjv.libs; then grep "/Users/" /tmp/jfjv.libs; echo "absolute path into the build machine"; exit 1; fi - # the (ad-hoc) signature must survive everything installed into the bundle + # the Developer ID signature must survive everything installed into the bundle, and carry + # the hardened runtime that notarization demands codesign --verify --deep --strict "$app" # rugnux is in an archive of its own, not in the app find "$app" -name rugnux > /tmp/jfjv.extra if [ -s /tmp/jfjv.extra ]; then cat /tmp/jfjv.extra; echo "the bundle carries another program"; exit 1; fi + codesign -d --verbose=2 "$app" 2>&1 | grep -q "flags=.*runtime" || { echo "no hardened runtime"; exit 1; } echo "self-contained and validly signed" - name: Verify rugnux is self-contained shell: bash @@ -293,17 +309,17 @@ jobs: # and it runs: with no arguments rugnux prints its usage "$bin" > /tmp/rgxm.out 2>&1 || true if ! grep -qi "usage" /tmp/rgxm.out; then cat /tmp/rgxm.out; echo "rugnux did not start"; exit 1; fi - echo "self-contained and runs" - # Notarization - needs an Apple Developer ID, which the project does not have yet. Once it - # does, the app has to be signed with it (hardened runtime, secure timestamp) BEFORE cpack - # packs it, e.g. by passing to macdeployqt, through qt_generate_deploy_app_script's - # DEPLOY_TOOL_OPTIONS in viewer/CMakeLists.txt: - # -codesign="Developer ID Application: ()" -hardened-runtime -timestamp - # and the DMG then notarized and stapled here, with an app-specific password stored once on - # the runner (xcrun notarytool store-credentials jfjoch-notary ...): - # xcrun notarytool submit build-macos/jfjoch-viewer-*.dmg --keychain-profile jfjoch-notary --wait - # xcrun stapler staple build-macos/jfjoch-viewer-*.dmg - # Until then a downloaded DMG is blocked by Gatekeeper on first open (right-click > Open). + # and carries the Developer ID signature with the hardened runtime that notarization demands + codesign --verify --strict "$bin" + codesign -d --verbose=2 "$bin" 2>&1 | grep -q "flags=.*runtime" || { echo "no hardened runtime"; exit 1; } + echo "self-contained, runs and is validly signed" + # Every push, not only a release: a rejection by Apple (a library macdeployqt left unsigned, an + # entitlement the hardened runtime needs) should show up when the change is made, not when the + # release is cut. Signs the image, waits for Apple's verdict, staples the ticket to the image; + # the rugnux binary only gets its hash registered, a bare executable has nowhere to staple. + - name: Notarize + shell: bash + run: tools/notarize_macos.sh build-macos/jfjoch-viewer-*.dmg build-macos/rugnux-*.tar.gz - name: Upload DMG and tgz to release if: github.event_name == 'workflow_dispatch' && (github.event.inputs.create_release == 'true' || github.event.inputs.create_release == true) shell: bash diff --git a/CMakeLists.txt b/CMakeLists.txt index 79801b719..1875640bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,15 @@ SET(JFJOCH_VIEWER_BUILD OFF CACHE BOOL "Compile Jungfraujoch viewer") # artifact has to ship a runtime library beside its executable. SET(JFJOCH_RUGNUX_ONLY OFF CACHE BOOL "Compile only rugnux, the offline analysis CLI (no Qt)") +# macOS only: the Developer ID Application certificate to sign the shipped program with at install +# time - hardened runtime and a secure timestamp, which is what Apple's notarization demands. Given +# as the certificate's SHA-1 hash, the 40 hex digits `security find-identity -v -p codesigning` +# prints in front of the name: the name itself ("Developer ID Application: ()") has +# spaces, and Qt's generated deploy script would split it into separate macdeployqt arguments. Empty +# (the default) keeps the ad-hoc signature, which runs locally but is refused by Gatekeeper after a +# download. Notarizing the packed artefact is a separate step after cpack: tools/notarize_macos.sh. +SET(JFJOCH_MACOS_SIGNING_IDENTITY "" CACHE STRING "macOS: SHA-1 of the Developer ID Application certificate to codesign with (empty: ad-hoc)") + # Only the viewer (and its portable dependency tree) is supported on Windows and macOS -- the # broker/receiver/FPGA/writer server stack is Linux-only. Force viewer-only on those platforms so a # plain configure builds the right subset; on Linux it remains a user-togglable option. A rugnux-only diff --git a/docs/JFJOCH_VIEWER.md b/docs/JFJOCH_VIEWER.md index 9a8f56721..81f65d372 100644 --- a/docs/JFJOCH_VIEWER.md +++ b/docs/JFJOCH_VIEWER.md @@ -10,8 +10,7 @@ Gitea release page, and for Linux also in the Jungfraujoch RPM/APT repositories [Release contents](RELEASE_CONTENTS.md) for what each package contains and what it requires, and [Deployment](DEPLOYMENT.md) for how to install it. The macOS build needs **macOS 13 (Ventura) or newer on an Apple Silicon Mac** (M1 and newer; Intel Macs are not supported) and is CPU-only — see -[Release contents ▸ macOS](RELEASE_CONTENTS.md#macos), which also covers opening it for the first -time, as the release is not yet notarized by Apple. +[Release contents ▸ macOS](RELEASE_CONTENTS.md#macos). ## Where it fits among the three analysis tools @@ -332,3 +331,11 @@ As on Windows, `CMAKE_PREFIX_PATH` (Qt) is the only required flag. To produce th Qt frameworks and the license notices copied into the app with `macdeployqt`, packed into `jfjoch-viewer--macos-arm64.dmg` — run `cpack` in the build directory. What comes out is described in [Release contents ▸ macOS](RELEASE_CONTENTS.md#macos). + +The released image is signed with the project's Apple Developer ID and notarized by Apple. A build of +your own is signed ad hoc, which runs on the machine that built it but is refused by Gatekeeper once +downloaded. To sign with a Developer ID from your own keychain, configure with +`-DJFJOCH_MACOS_SIGNING_IDENTITY=` before `cpack`, where the hash is the 40 hex digits +`security find-identity -v -p codesigning` prints in front of the certificate's name, then notarize +and staple the image with `tools/notarize_macos.sh build-mac/jfjoch-viewer-*.dmg` (it needs +notarytool credentials stored once on the machine; the script's header says how). diff --git a/docs/RELEASE_CONTENTS.md b/docs/RELEASE_CONTENTS.md index 397cf0451..9fa9b9bbb 100644 --- a/docs/RELEASE_CONTENTS.md +++ b/docs/RELEASE_CONTENTS.md @@ -173,17 +173,10 @@ app onto the *Applications* shortcut next to it. The app is self-contained: the inside it, and so are the license notices (`Contents/Resources`), which is where a macOS app keeps them. -**The release is not notarized yet** (that needs an Apple Developer account), so macOS refuses to -open a downloaded copy the first time. On macOS 15 and newer: try to open it once, then allow it in -*System Settings ▸ Privacy & Security* with *Open Anyway*. On macOS 13 and 14, Control-click the app -and choose *Open*. Alternatively, clear the download flag in Terminal: - -``` -xattr -dr com.apple.quarantine /Applications/jfjoch_viewer.app -``` - -The same applies to the `rugnux` binary from its `.tgz` — see -[Installing Rugnux](RUGNUX_INSTALL.md#from-the-release-archive). +Both artefacts are signed with the project's Apple Developer ID and **notarized by Apple**, so a +downloaded copy opens like any other Mac application. The disk image carries its notarization ticket; +the `rugnux` binary in the `.tgz` cannot (a bare executable has nowhere to staple one), so the first +run of a freshly downloaded `rugnux` looks the ticket up online and needs an internet connection. The toolchain of the released macOS artefacts is Xcode (Apple Clang) and Qt 6.11 for macOS, including Qt Charts; to build the viewer yourself see diff --git a/docs/RUGNUX_INSTALL.md b/docs/RUGNUX_INSTALL.md index 5f9b42422..b80128459 100644 --- a/docs/RUGNUX_INSTALL.md +++ b/docs/RUGNUX_INSTALL.md @@ -62,17 +62,10 @@ Nothing is written outside that directory, nothing needs root, and several versi side. To remove it, delete the directory. Put `bin/` on your `PATH` if you want to type `rugnux` rather than the full path. -> **macOS: the first run is blocked.** The release is not yet notarized by Apple, and a `.tgz` -> downloaded with a browser hands its download flag on to everything `tar` extracts from it, so -> macOS refuses to run `rugnux` ("cannot be opened because the developer cannot be verified"). Clear -> the flag once for the whole directory: -> -> ``` -> xattr -dr com.apple.quarantine ~/rugnux-1.0.0 -> ``` -> -> An archive fetched with `curl` carries no such flag. Unpacking into a directory under your home -> directory (`~/rugnux-`) rather than `/opt` avoids needing `sudo` on a Mac. +> **macOS:** the binary is signed with the project's Apple Developer ID and notarized, so a `.tgz` +> downloaded with a browser runs without further steps; the first run of a freshly downloaded copy +> checks the notarization online. Unpacking into a directory under your home directory +> (`~/rugnux-`) rather than `/opt` avoids needing `sudo` on a Mac. > **Mixing the two.** If a `rugnux` package is also installed, `/usr/bin/rugnux` will normally win > on `PATH`. Put the archive's `bin/` first, or call it by its full path, to be sure which one you diff --git a/rugnux/CMakeLists.txt b/rugnux/CMakeLists.txt index 3748f9808..27eb8cfb1 100644 --- a/rugnux/CMakeLists.txt +++ b/rugnux/CMakeLists.txt @@ -37,6 +37,16 @@ TARGET_LINK_LIBRARIES(JFJochRugnux JFJochReader JFJochImageAnalysis JFJochWriter ADD_EXECUTABLE(rugnux rugnux_cli.cpp RugnuxConsole.cpp RugnuxConsole.h) TARGET_LINK_LIBRARIES(rugnux JFJochRugnux JFJochReader JFJochImageAnalysis JFJochWriter) INSTALL(TARGETS rugnux RUNTIME COMPONENT rugnux) +# macOS: sign the installed binary with the Developer ID (hardened runtime, secure timestamp) so the +# .tgz can be notarized - see JFJOCH_MACOS_SIGNING_IDENTITY in the top-level CMakeLists.txt. The +# binary links only system libraries, so no entitlements are needed. +IF(APPLE AND JFJOCH_MACOS_SIGNING_IDENTITY) + INSTALL(CODE "execute_process(COMMAND codesign --force --options runtime --timestamp + --sign \"${JFJOCH_MACOS_SIGNING_IDENTITY}\" + \"\$ENV{DESTDIR}\${CMAKE_INSTALL_PREFIX}/bin/rugnux\" + COMMAND_ERROR_IS_FATAL ANY)" + COMPONENT rugnux) +ENDIF() # libcufft_static.a carries a relocatable-device-code object (separate_callback.o), so an executable # linking it needs a CUDA device-link step -- without it the host link fails on an undefined diff --git a/tools/notarize_macos.sh b/tools/notarize_macos.sh new file mode 100755 index 000000000..84d98c567 --- /dev/null +++ b/tools/notarize_macos.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# Notarize the macOS release artefacts with Apple and staple the ticket where one can be stapled. +# +# tools/notarize_macos.sh ... +# +# The programs inside must already carry a Developer ID signature with hardened runtime and secure +# timestamp: configure with -DJFJOCH_MACOS_SIGNING_IDENTITY= and let cpack produce +# the artefact (see the top-level CMakeLists.txt). The same certificate signs the .dmg here: the +# one JFJOCH_MACOS_SIGNING_IDENTITY names, else the Developer ID Application in the keychain. Needs +# notarytool credentials stored once on the machine, under the profile named by JFJOCH_NOTARY_PROFILE +# (default jfjoch-notary). On an unattended machine store them in the login keychain file: without +# --keychain they go into the data-protection keychain, which is unreadable while the screen is +# locked, and the step then fails with "No Keychain password item found". +# +# xcrun notarytool store-credentials jfjoch-notary --apple-id --team-id \ +# --keychain ~/Library/Keychains/login.keychain-db +# +# .dmg signed with the same Developer ID, submitted, the ticket stapled to the image. +# .tar.gz the rugnux binary inside is zipped and submitted; Apple then knows its hash, which is +# all Gatekeeper needs (a bare executable has nowhere to staple a ticket). The archive is +# shipped unchanged. +set -euo pipefail + +profile=${JFJOCH_NOTARY_PROFILE:-jfjoch-notary} +identity=${JFJOCH_MACOS_SIGNING_IDENTITY:-$(security find-identity -v -p codesigning | sed -n 's/^ *[0-9]*) \([0-9A-F]*\) "Developer ID Application: .*/\1/p' | head -1)} +if [ -z "$identity" ]; then echo "no Developer ID Application identity in the keychain"; exit 1; fi + +submit() { # submit : wait for Apple's verdict, print the log on rejection + local out id status + out=$(xcrun notarytool submit "$1" --keychain-profile "$profile" --wait --output-format plist) + id=$(echo "$out" | plutil -extract id raw -) + status=$(echo "$out" | plutil -extract status raw -) + echo "notarization of $(basename "$1"): $status (submission $id)" + if [ "$status" != "Accepted" ]; then + xcrun notarytool log "$id" --keychain-profile "$profile" + exit 1 + fi +} + +for file in "$@"; do + case "$file" in + *.dmg) + codesign --force --timestamp --sign "$identity" "$file" + submit "$file" + xcrun stapler staple "$file" + spctl -a -t open --context context:primary-signature -v "$file" + ;; + *.tar.gz|*.tgz) + tmp=$(mktemp -d) + tar xf "$file" -C "$tmp" + bin=$(find "$tmp" -name rugnux -type f) + ditto -c -k "$bin" "$tmp/rugnux.zip" + submit "$tmp/rugnux.zip" + rm -rf "$tmp" + ;; + *) + echo "don't know how to notarize $file"; exit 1 + ;; + esac +done diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index e11938c98..5d3b0d59b 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -246,11 +246,20 @@ qt_import_plugins(jfjoch_viewer EXCLUDE Qt::QJpegPlugin Qt::QTiffPlugin) # which is when the OUTPUT_SCRIPT signature and the Linux deploy path landed (6.3/6.4 used the older # FILENAME_VARIABLE and had no Linux support); skipped on older Qt. NO_UNSUPPORTED_PLATFORM_ERROR # keeps it from hard-failing on configurations it does not handle. +# On macOS macdeployqt also signs the bundle (ad-hoc by default); with JFJOCH_MACOS_SIGNING_IDENTITY +# set it signs with that Developer ID instead, hardened runtime and secure timestamp included, which +# is what the .dmg needs to be notarized afterwards (DEPLOY_TOOL_OPTIONS needs Qt >= 6.7). IF(Qt6_VERSION VERSION_GREATER_EQUAL "6.5") + SET(_viewer_deploy_tool_options) + IF(APPLE AND JFJOCH_MACOS_SIGNING_IDENTITY) + SET(_viewer_deploy_tool_options + DEPLOY_TOOL_OPTIONS "-sign-for-notarization=${JFJOCH_MACOS_SIGNING_IDENTITY}") + ENDIF() qt_generate_deploy_app_script( TARGET jfjoch_viewer OUTPUT_SCRIPT jfjoch_viewer_deploy_script - NO_UNSUPPORTED_PLATFORM_ERROR) + NO_UNSUPPORTED_PLATFORM_ERROR + ${_viewer_deploy_tool_options}) INSTALL(SCRIPT ${jfjoch_viewer_deploy_script} COMPONENT viewer) ENDIF()