diff --git a/.github/scripts/macos-notarize.sh b/.github/scripts/macos-notarize.sh new file mode 100755 index 00000000..059c4e84 --- /dev/null +++ b/.github/scripts/macos-notarize.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Notarize a signed macOS app bundle or DMG and staple the ticket to it. +# +# Usage: macos-notarize.sh +# +# Needs APPLE_API_KEY_P8 (the App Store Connect API key, base64), APPLE_API_KEY_ID +# and APPLE_API_ISSUER_ID. Writes Apple's notary log to the second argument, +# also when notarization fails, and exits non-zero unless it was accepted. +set -euo pipefail + +target="$1" +log="$2" + +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT +printf '%s' "$APPLE_API_KEY_P8" | base64 --decode > "$workdir/AuthKey.p8" +auth=(--key "$workdir/AuthKey.p8" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER_ID") + +upload="$target" +if [[ -d "$target" ]]; then + # notarytool takes a zip, dmg or pkg, not a bundle. + upload="$workdir/$(basename "$target").zip" + ditto -c -k --keepParent "$target" "$upload" +fi + +# Usually minutes, at times an hour. +xcrun notarytool submit "$upload" "${auth[@]}" --wait --timeout 1h --output-format json \ + > "$workdir/submit.json" || true +cat "$workdir/submit.json" +id="$(jq -r '.id // empty' "$workdir/submit.json" 2>/dev/null || true)" +status="$(jq -r '.status // empty' "$workdir/submit.json" 2>/dev/null || true)" +if [[ -n "$id" ]]; then + xcrun notarytool log "$id" "${auth[@]}" "$log" || true +fi +if [[ "$status" != "Accepted" ]]; then + echo "::error::Notarization of $target: ${status:-no result}" + [[ -f "$log" ]] && cat "$log" + exit 1 +fi + +xcrun stapler staple "$target" +xcrun stapler validate "$target" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 874748cc..08901366 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,9 +7,9 @@ name: Release # and stay available as workflow artifacts, since they exceed the 2 GiB # release-asset limit; the GitHub release carries the sha256 checksums # and the CycloneDX SBOM (#213) for verification. Flatpak (needs Flathub -# manifest refresh) is a follow-up. An Apple silicon macOS DMG (not yet -# signed or notarized) is added to the same Zenodo draft, its checksums and -# SBOM to the release. +# manifest refresh) is a follow-up. An Apple silicon macOS DMG is added to the +# same Zenodo draft, its checksums and SBOM to the release, only after Developer +# ID signing and notarization. Ad-hoc builds remain available as test artifacts. on: push: @@ -396,16 +396,26 @@ jobs: .github/scripts/zenodo-upload.sh "$version" artifacts/aTrain-* artifacts/checksums.txt build-macos-dmg: - name: Build macOS DMG (unsigned, Apple silicon) + name: Build macOS DMG (Apple silicon) # macos-15 is arm64; macos-latest moves to a new macOS over time. runs-on: macos-15 - timeout-minutes: 60 + # Notarization uploads the app and the DMG (about 400 MB each) and can + # take up to an hour on Apple's side. + timeout-minutes: 90 outputs: version: ${{ steps.version.outputs.version }} + # Set only after signing, notarization and verification succeed. The + # publishing job requires it so ad-hoc test builds cannot become releases. + signing-verified: ${{ steps.sign-dmg.outputs.verified }} env: # Wheels are chosen for this macOS, not the runner's (orjson ships a # macosx_15_0 wheel next to an 11.0 one). Matches LSMinimumSystemVersion. MACOSX_DEPLOYMENT_TARGET: "14.0" + # Developer ID signing and notarization run only with the Apple secrets; + # without them (forks, contributors) the app keeps PyInstaller's ad-hoc + # signature. Only this flag is job-wide: the secrets go to the signing + # steps alone, not to uv, PyInstaller or the app. + MACOS_SIGN: ${{ secrets.MACOS_CERTIFICATE_P12 != '' && secrets.APPLE_API_KEY_P8 != '' }} steps: - name: Checkout @@ -477,6 +487,51 @@ jobs: set -euo pipefail uv run --no-sync pyinstaller macos_freeze.spec --noconfirm 2>&1 | tee pyinstaller-macos.log + - name: Sign the app with the Developer ID + # Inside out, without --deep (deprecated for signing): every Mach-O + # file, then the frameworks, then the bundle with its main executable. + # The keychain lives only for this job, see the cleanup step. + if: env.MACOS_SIGN == 'true' + shell: bash + env: + MACOS_CERTIFICATE_P12: ${{ secrets.MACOS_CERTIFICATE_P12 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + run: | + set -euo pipefail + keychain="$RUNNER_TEMP/signing.keychain-db" + keychain_password="$(openssl rand -base64 32)" + security create-keychain -p "$keychain_password" "$keychain" + security set-keychain-settings -lut 21600 "$keychain" + security unlock-keychain -p "$keychain_password" "$keychain" + printf '%s' "$MACOS_CERTIFICATE_P12" | base64 --decode > "$RUNNER_TEMP/certificate.p12" + security import "$RUNNER_TEMP/certificate.p12" -k "$keychain" \ + -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + rm "$RUNNER_TEMP/certificate.p12" + security set-key-partition-list -S apple-tool:,apple: -s -k "$keychain_password" "$keychain" > /dev/null + security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"') + # A line break in the secret would split the grep pattern and match anything. + if [[ "$MACOS_SIGNING_IDENTITY" == *$'\n'* ]] \ + || ! security find-identity -v -p codesigning "$keychain" | grep -qF "\"$MACOS_SIGNING_IDENTITY\""; then + echo "::error::MACOS_SIGNING_IDENTITY is not in MACOS_CERTIFICATE_P12 (or ends with a line break)" + exit 1 + fi + + sign() { + # --timestamp needs Apple's timestamp server: one retry. + codesign --force --timestamp --options runtime --sign "$MACOS_SIGNING_IDENTITY" "$@" \ + || { sleep 10; codesign --force --timestamp --options runtime --sign "$MACOS_SIGNING_IDENTITY" "$@"; } + } + app=dist/aTrain.app + find "$app/Contents" -type f ! -path "$app/Contents/MacOS/aTrain" -print0 \ + | xargs -0 file | grep 'Mach-O' | cut -d: -f1 > sign-files.txt + echo "Signing $(wc -l < sign-files.txt | tr -d ' ') Mach-O files" + while IFS= read -r f; do sign "$f"; done < sign-files.txt + # Deepest first, so a framework is sealed after anything inside it. + find "$app/Contents" -type d -name '*.framework' | awk -F/ '{ print NF, $0 }' | sort -rn | cut -d' ' -f2- \ + | while IFS= read -r framework; do sign "$framework"; done + sign --entitlements entitlements.plist "$app" + - name: Smoke test - architecture shell: bash run: | @@ -519,14 +574,33 @@ jobs: - name: Smoke test - code signature # PyInstaller signs ad hoc on arm64; an unsigned arm64 binary will - # not launch. + # not launch. A Developer ID build must carry a Team ID and the + # hardened runtime, and no file may be left signed ad hoc. shell: bash - run: codesign --verify --deep --strict --verbose=2 dist/aTrain.app + run: | + set -euo pipefail + codesign --verify --deep --strict --verbose=2 dist/aTrain.app + [[ "$MACOS_SIGN" == 'true' ]] || exit 0 + codesign -dv --verbose=2 dist/aTrain.app 2>&1 | tee app-signature.txt + if grep -q '^TeamIdentifier=not set' app-signature.txt \ + || ! grep -Eq 'flags=0x[0-9a-f]+\([^)]*runtime' app-signature.txt; then + echo "::error::The app has no Team ID or no hardened runtime" + exit 1 + fi + while IFS= read -r f; do + if codesign -dv "$f" 2>&1 | grep -q 'Signature=adhoc'; then echo "$f"; fi + done < macho-files.txt > adhoc.txt + if [[ -s adhoc.txt ]]; then + echo "::error::Files still signed ad hoc:" + cat adhoc.txt + exit 1 + fi - name: Smoke test - the app starts # Not --help: the frozen entry point ignores arguments and starts the GUI. # `script` gives the app a pty, so its stdout is line-buffered (a frozen # app ignores PYTHONUNBUFFERED) and the log is complete after 60 s. + # After signing, so it runs under the hardened runtime that ships. shell: bash run: | set -euo pipefail @@ -547,9 +621,24 @@ jobs: cat app-start.log exit "$status" + - name: Notarize the app + # Before the DMG, so the app inside carries the stapled ticket and + # Gatekeeper accepts it offline, also once copied out of the DMG. + if: env.MACOS_SIGN == 'true' + shell: bash + env: + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + run: | + set -euo pipefail + .github/scripts/macos-notarize.sh dist/aTrain.app notary-app.json + spctl -a -vvv -t exec dist/aTrain.app 2>&1 | tee spctl-app.txt + grep -q 'source=Notarized Developer ID' spctl-app.txt + - name: Create the DMG - # The .app must not change after the ad-hoc signing; ditto preserves - # the bundle (symlinks, xattrs) as built. + # The .app must not change after signing; ditto preserves the bundle + # (symlinks, xattrs) as built. shell: bash run: | set -euo pipefail @@ -573,6 +662,30 @@ jobs: hdiutil detach "$mountpoint" exit "$status" + - name: Sign and notarize the DMG + id: sign-dmg + if: env.MACOS_SIGN == 'true' + shell: bash + env: + MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + APPLE_API_KEY_P8: ${{ secrets.APPLE_API_KEY_P8 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + run: | + set -euo pipefail + dmg="aTrain-${{ steps.version.outputs.version }}-macos-arm64.dmg" + codesign --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$dmg" \ + || { sleep 10; codesign --timestamp --sign "$MACOS_SIGNING_IDENTITY" "$dmg"; } + .github/scripts/macos-notarize.sh "$dmg" notary-dmg.json + codesign --verify --strict --verbose=2 "$dmg" + spctl -a -vvv -t open --context context:primary-signature "$dmg" + echo "verified=true" >> "$GITHUB_OUTPUT" + + - name: Remove the signing keychain + if: always() && env.MACOS_SIGN == 'true' + shell: bash + run: security delete-keychain "$RUNNER_TEMP/signing.keychain-db" || true + - name: Compute checksums shell: bash run: | @@ -603,6 +716,7 @@ jobs: build/macos_freeze/warn-macos_freeze.txt app-start.log minos.txt + notary-*.json if-no-files-found: ignore macos27-app-start: @@ -677,6 +791,17 @@ jobs: ZENODO_TOKEN: ${{ secrets.ZENODO_TOKEN }} steps: + - name: Require a signed and notarized DMG + shell: bash + env: + MACOS_SIGNING_VERIFIED: ${{ needs.build-macos-dmg.outputs.signing-verified }} + run: | + set -euo pipefail + if [[ "${MACOS_SIGNING_VERIFIED:-}" != 'true' ]]; then + echo "::error::macOS publishing requires a signed, notarized and verified DMG. Unsigned builds remain available as workflow artifacts." + exit 1 + fi + - name: Checkout uses: actions/checkout@v7 diff --git a/docs/code-signing-policy.md b/docs/code-signing-policy.md index 64e56541..b5119ec4 100644 --- a/docs/code-signing-policy.md +++ b/docs/code-signing-policy.md @@ -7,14 +7,33 @@ Free code signing provided by [SignPath.io](https://signpath.io), certificate by - **Windows MSIX from GitHub releases and Zenodo**: Authenticode-signed through SignPath.io with a certificate issued to SignPath Foundation, from v1.5.0 on. Earlier builds are unsigned. +- **macOS DMG from Zenodo**: the app and the disk image are signed with the + Apple Developer ID Application certificate + `Developer ID Application: Juergen Fleiss (ZC3J2Z2Q96)`, with hardened runtime, and + notarized by Apple; the ticket is stapled to both. Earlier macOS builds are + not notarized. - **Microsoft Store**: packages are signed by Microsoft as part of Store publishing. - **Flathub**: Flatpak packages carry no Authenticode signature; integrity is handled by Flathub's build and distribution infrastructure. Signing runs in the release workflow (`.github/workflows/release.yml`) on a -tagged commit. Every signing request is approved manually by an approver -before the certificate is applied. +tagged commit. Every Windows signing request is approved manually by an +approver before the certificate is applied. macOS signing runs in the same +workflow without a manual approval. It uses the repository secrets +`MACOS_CERTIFICATE_P12`, `MACOS_CERTIFICATE_PASSWORD` and +`MACOS_SIGNING_IDENTITY` (certificate) and `APPLE_API_KEY_P8`, +`APPLE_API_KEY_ID` and `APPLE_API_ISSUER_ID` (App Store Connect API key, role +Developer, for notarization), which only repository admins can change. The +certificate expires after five years and the API key can be revoked; either +shows up as a failing signing step in the release workflow. If they leak, +revoke both at Apple. + +macOS publishing requires successful signing, notarization, and verification +of the DMG. If signing secrets are missing, the workflow still builds an +ad-hoc-signed DMG for testing and keeps it as a workflow artifact, but the +macOS publishing job for a tagged release fails before uploading anything +to GitHub or Zenodo. How to check a downloaded release against the published checksums and this signature: [Verifying a release](verifying-releases.md). diff --git a/docs/verifying-releases.md b/docs/verifying-releases.md index c9b62a5f..973b9087 100644 --- a/docs/verifying-releases.md +++ b/docs/verifying-releases.md @@ -17,7 +17,7 @@ Every release is a git tag `v` and a GitHub release built from it by | `checksums.txt` | GitHub release and Zenodo | SHA-256 of every file below, computed after signing. Both copies are identical. | | `aTrain--windows.cdx.json` | GitHub release | CycloneDX SBOM of the Windows build. | | `aTrain--source.tar.gz` | GitHub (tag archive) | Source at the tag; its hash is in `checksums.txt`. | -| `aTrain--macos-arm64.dmg` | Zenodo | macOS disk image, Apple silicon, unsigned and not notarized. | +| `aTrain--macos-arm64.dmg` | Zenodo | macOS disk image, Apple silicon, signed with a Developer ID and notarized by Apple. | | `checksums-macos.txt` | GitHub release and Zenodo | SHA-256 of the DMG and its SBOM. | | `aTrain--macos.cdx.json` | GitHub release and Zenodo | CycloneDX SBOM of the macOS build. | @@ -59,10 +59,6 @@ matching line in `checksums-macos.txt` from the GitHub release: shasum -a 256 aTrain--macos-arm64.dmg ``` -The macOS build is unsigned for now, so there is no signature to verify, and -Gatekeeper blocks the first launch. Only after the checksum matches, open it -through System Settings → Privacy & Security → Open Anyway. - A mismatch means a corrupted download or a file that is not the released one. Do not install it; download again, then report if it persists (see below). @@ -77,7 +73,24 @@ Get-AuthenticodeSignature .\aTrain-.msix | Format-List Status, SignerCe applied through SignPath.io. The policy page also lists who may approve a signing request. -## 3. Verify the source +## 3. Check the Developer ID signature (macOS app) + +Open the DMG, then check the app on the mounted volume: + +```bash +codesign --verify --deep --strict --verbose=2 /Volumes/aTrain/aTrain.app +codesign -dv --verbose=4 /Volumes/aTrain/aTrain.app 2>&1 | grep -E '^(Authority|TeamIdentifier)=' +spctl -a -vvv -t exec /Volumes/aTrain/aTrain.app +``` + +`codesign --verify` must pass. The first `Authority` must be the Developer ID +named in the [Code signing policy](code-signing-policy.md) and +`TeamIdentifier` its Team ID. `spctl` must print `accepted` and +`source=Notarized Developer ID`. The notarization ticket is stapled to the DMG +and the app, so Gatekeeper accepts them without an online check. A build +without these (for example a fork's test build) is not a release. + +## 4. Verify the source For builds from source (Flatpak, distribution packages), work from the tag, not from a branch: @@ -89,13 +102,14 @@ git clone --branch v https://github.com/aTrainTranscription/aTrain The tag archive `aTrain--source.tar.gz` is listed in `checksums.txt`, so a downloaded tarball can be checked the same way as the installer. -## 4. Read the SBOM +## 5. Read the SBOM `aTrain--windows.cdx.json` lists every Python package in the Windows build with version and licence, plus the bundled ML models and their Hugging Face revisions. Use it to scan for known vulnerabilities, for example with `pip-audit`, `grype`, or `trivy sbom`, or to review licences before -redistribution. +redistribution. `aTrain--macos.cdx.json` does the same for the macOS +build. ## Reporting a mismatch diff --git a/entitlements.plist b/entitlements.plist index 3417411e..3f5f5050 100644 --- a/entitlements.plist +++ b/entitlements.plist @@ -1,6 +1,6 @@ - + - +