From 6d375fcc6318425c47b26605110ef543480bac98 Mon Sep 17 00:00:00 2001 From: Igor Arkhipov Date: Sun, 12 Jul 2026 18:26:16 -0700 Subject: [PATCH 1/7] Preserve macOS permission identity across releases --- .github/macos-release-signing.md | 27 +++++++ .github/workflows/release.yml | 82 +++++++++++++++++++++- scripts/build_macos_app.sh | 32 ++++++++- scripts/macos_distribution_signing.sh | 55 +++++++++++++++ scripts/notarize_macos_dmg.sh | 38 ++++++++++ scripts/test_macos_distribution_signing.sh | 71 +++++++++++++++++++ 6 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 .github/macos-release-signing.md create mode 100755 scripts/macos_distribution_signing.sh create mode 100755 scripts/notarize_macos_dmg.sh create mode 100755 scripts/test_macos_distribution_signing.sh diff --git a/.github/macos-release-signing.md b/.github/macos-release-signing.md new file mode 100644 index 00000000..af84b99a --- /dev/null +++ b/.github/macos-release-signing.md @@ -0,0 +1,27 @@ +# macOS release signing + +Entropy releases must use one Developer ID Application identity across versions. macOS privacy permissions use code-signing designated requirements to recognize updates as the same app; ad-hoc signatures instead produce a requirement tied to one binary hash. + +Configure these GitHub Actions repository secrets before pushing a `v0.*` tag: + +| Secret | Value | +| --- | --- | +| `MACOS_CERTIFICATE_P12_BASE64` | Base64-encoded `.p12` containing Developer ID Application certificate and private key | +| `MACOS_CERTIFICATE_PASSWORD` | `.p12` export password | +| `MACOS_SIGNING_IDENTITY` | Full identity name, for example `Developer ID Application: Example Developer (TEAM123456)` | +| `APPLE_NOTARY_KEY_P8_BASE64` | Base64-encoded App Store Connect team API private key | +| `APPLE_NOTARY_KEY_ID` | App Store Connect API key ID | +| `APPLE_NOTARY_ISSUER_ID` | App Store Connect API issuer ID | + +The release workflow imports the certificate into a temporary keychain, enables hardened runtime, signs app and DMG, validates that app has stable Developer ID designated requirement, notarizes DMG, staples ticket, and runs Gatekeeper assessment. Missing credentials fail release before artifact upload. + +First Developer ID-signed release has different identity from old ad-hoc releases. Existing users must remove old Entropy entries from Accessibility and Input Monitoring, add current app again, and grant permissions once. Later releases signed with same Developer ID preserve that identity. + +Inspect built app identity with: + +```bash +codesign -dv --verbose=4 Entropy.app +codesign -d -r- Entropy.app +``` + +Expected output includes Developer ID Application authority and Team ID. Designated requirement must not be `cdhash`-only. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d4a9135b..ab2d7c1b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,6 +45,42 @@ jobs: if: matrix.kind == 'macos' run: rustup target add ${{ matrix.target }} + - name: Import macOS Developer ID certificate + if: matrix.kind == 'macos' + shell: bash + env: + CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} + MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + run: | + umask 077 + if [[ -z "$CERTIFICATE_P12_BASE64" ]] || + [[ -z "$MACOS_CERTIFICATE_PASSWORD" ]]; then + echo "macOS Developer ID certificate secrets are not configured" >&2 + exit 1 + fi + + certificate_path="$RUNNER_TEMP/entropy-developer-id.p12" + keychain_path="$RUNNER_TEMP/entropy-signing.keychain-db" + keychain_password="$(openssl rand -base64 32)" + + printf '%s' "$CERTIFICATE_P12_BASE64" | + base64 -D > "$certificate_path" + security create-keychain -p "$keychain_password" "$keychain_path" + security set-keychain-settings -lut 21600 "$keychain_path" + security unlock-keychain -p "$keychain_password" "$keychain_path" + security import "$certificate_path" \ + -P "$MACOS_CERTIFICATE_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$keychain_path" + security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$keychain_password" \ + "$keychain_path" + security list-keychains -d user -s "$keychain_path" + - name: Install Linux dependencies if: matrix.os == 'ubuntu-latest' run: | @@ -81,11 +117,52 @@ jobs: - name: Build macOS app bundle and DMG if: matrix.kind == 'macos' shell: bash + env: + CODESIGN_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} + REQUIRE_DISTRIBUTION_SIGNING: '1' run: | TARGET="${{ matrix.target }}" scripts/build_macos_app.sh + + - name: Notarize macOS DMG + if: matrix.kind == 'macos' + shell: bash + env: + APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} + APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} + APPLE_NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} + run: | + umask 077 + if [[ -z "$APPLE_NOTARY_KEY_P8_BASE64" ]]; then + echo "Apple notarization key secret is not configured" >&2 + exit 1 + fi + + notary_key_path="$RUNNER_TEMP/AuthKey_${APPLE_NOTARY_KEY_ID}.p8" + printf '%s' "$APPLE_NOTARY_KEY_P8_BASE64" | + base64 -D > "$notary_key_path" + chmod 600 "$notary_key_path" + + dmg_paths=(dist/macos/*.dmg) + if [[ ${#dmg_paths[@]} -ne 1 || ! -f "${dmg_paths[0]}" ]]; then + echo "Expected exactly one macOS DMG" >&2 + exit 1 + fi + + APPLE_NOTARY_KEY_PATH="$notary_key_path" \ + scripts/notarize_macos_dmg.sh "${dmg_paths[0]}" mkdir -p dist/release asset="entropy-${GITHUB_REF_NAME}-macos-${{ matrix.arch }}.dmg" - cp dist/macos/*.dmg "dist/release/$asset" + cp "${dmg_paths[0]}" "dist/release/$asset" + + - name: Remove macOS signing credentials + if: matrix.kind == 'macos' && always() + shell: bash + run: | + keychain_path="$RUNNER_TEMP/entropy-signing.keychain-db" + security delete-keychain "$keychain_path" || true + rm -f \ + "$RUNNER_TEMP/entropy-developer-id.p12" \ + "$RUNNER_TEMP"/AuthKey_*.p8 - name: Upload artifact uses: actions/upload-artifact@v4 @@ -121,7 +198,8 @@ jobs: printf '\n## Downloads\n\n' >> RELEASE_NOTES.md printf '%s\n' '- Linux: download the `.AppImage`, make it executable, and run it.' >> RELEASE_NOTES.md printf '%s\n' '- Windows: download and run the portable `.exe`.' >> RELEASE_NOTES.md - printf '%s\n' '- macOS: download the unsigned `.dmg` for your Mac (`arm64` for Apple Silicon, `x86_64` for Intel), drag Entropy to Applications, then remove quarantine if macOS blocks the app.' >> RELEASE_NOTES.md + printf '%s\n' '- macOS: download the signed and notarized `.dmg` for your Mac (`arm64` for Apple Silicon, `x86_64` for Intel), then drag Entropy to Applications.' >> RELEASE_NOTES.md + printf '%s\n' '- When upgrading from Entropy v0.2.0 or earlier, remove old Entropy entries from Accessibility and Input Monitoring, then grant both permissions once for the signed app. Later signed updates preserve this identity.' >> RELEASE_NOTES.md - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/scripts/build_macos_app.sh b/scripts/build_macos_app.sh index c1fe984c..087081bd 100755 --- a/scripts/build_macos_app.sh +++ b/scripts/build_macos_app.sh @@ -5,9 +5,12 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_NAME="${APP_NAME:-Entropy}" BUNDLE_ID="${BUNDLE_ID:-com.ergohaven.entropy}" CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}" +REQUIRE_DISTRIBUTION_SIGNING="${REQUIRE_DISTRIBUTION_SIGNING:-0}" MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-10.15}" export MACOSX_DEPLOYMENT_TARGET +source "$ROOT/scripts/macos_distribution_signing.sh" + VERSION="$( awk -F '"' '/^version = / { print $2; exit }' "$ROOT/Cargo.toml" )" @@ -81,6 +84,10 @@ sign_app_bundle() { fi if ! command -v codesign >/dev/null 2>&1; then + if [[ "$REQUIRE_DISTRIBUTION_SIGNING" == "1" ]]; then + echo "codesign not found; cannot build a signed release" >&2 + return 1 + fi echo "codesign not found; skipped app bundle signing" return fi @@ -88,10 +95,25 @@ sign_app_bundle() { local codesign_args=(--force --sign "$CODESIGN_IDENTITY") if [[ "$CODESIGN_IDENTITY" == "-" ]]; then codesign_args+=(--timestamp=none) + else + codesign_args+=(--options runtime --timestamp) fi codesign "${codesign_args[@]}" "$APP_PATH" - codesign --verify --strict "$APP_PATH" + if [[ "$REQUIRE_DISTRIBUTION_SIGNING" == "1" ]]; then + macos_verify_distribution_signature "$APP_PATH" + else + codesign --verify --strict "$APP_PATH" + fi +} + +sign_disk_image() { + if [[ "$CODESIGN_IDENTITY" == "-" ]]; then + return + fi + + codesign --force --timestamp --sign "$CODESIGN_IDENTITY" "$DMG_PATH" + codesign --verify --verbose=2 "$DMG_PATH" } create_dmg_with_retries() { @@ -125,6 +147,10 @@ create_dmg_with_retries() { return "$status" } +macos_validate_signing_configuration \ + "$CODESIGN_IDENTITY" \ + "$REQUIRE_DISTRIBUTION_SIGNING" + cd "$ROOT" cargo build "${BUILD_ARGS[@]}" validate_binary_arch @@ -188,6 +214,10 @@ fi if command -v hdiutil >/dev/null 2>&1; then create_dmg_with_retries + sign_disk_image +elif [[ "$REQUIRE_DISTRIBUTION_SIGNING" == "1" ]]; then + echo "hdiutil not found; cannot build a signed release DMG" >&2 + exit 1 else echo "hdiutil not found; skipped DMG build" fi diff --git a/scripts/macos_distribution_signing.sh b/scripts/macos_distribution_signing.sh new file mode 100755 index 00000000..ab80d924 --- /dev/null +++ b/scripts/macos_distribution_signing.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + +macos_validate_signing_configuration() { + local identity="${1:-}" + local required="${2:-0}" + + if [[ "$required" != "0" && "$required" != "1" ]]; then + echo "REQUIRE_DISTRIBUTION_SIGNING must be 0 or 1" >&2 + return 1 + fi + + if [[ "$required" == "1" && ( -z "$identity" || "$identity" == "-" ) ]]; then + echo "Developer ID signing is required; refusing to build an ad-hoc release" >&2 + return 1 + fi +} + +macos_validate_distribution_signature_output() { + local details="$1" + local requirement="$2" + + if [[ "$details" == *"Signature=adhoc"* ]]; then + echo "Distribution app is ad-hoc signed" >&2 + return 1 + fi + if [[ "$details" != *"TeamIdentifier="* ]] || + [[ "$details" == *"TeamIdentifier=not set"* ]]; then + echo "Distribution app has no Team ID" >&2 + return 1 + fi + if [[ "$details" != *"Authority=Developer ID Application:"* ]]; then + echo "Distribution app is not signed with a Developer ID Application certificate" >&2 + return 1 + fi + if [[ "$requirement" != *"# designated =>"* ]]; then + echo "Distribution app has no designated requirement" >&2 + return 1 + fi + if [[ "$requirement" == *"# designated => cdhash "* ]]; then + echo "Distribution app designated requirement is tied to one binary hash" >&2 + return 1 + fi +} + +macos_verify_distribution_signature() { + local app_path="$1" + local details + local requirement + + codesign --verify --deep --strict --verbose=2 "$app_path" || return 1 + details="$(codesign -dv --verbose=4 "$app_path" 2>&1)" || return 1 + requirement="$(codesign -d -r- "$app_path" 2>&1)" || return 1 + macos_validate_distribution_signature_output "$details" "$requirement" || return 1 + echo "Validated stable Developer ID signature for $app_path" +} diff --git a/scripts/notarize_macos_dmg.sh b/scripts/notarize_macos_dmg.sh new file mode 100755 index 00000000..9272620f --- /dev/null +++ b/scripts/notarize_macos_dmg.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi + +DMG_PATH="$1" +: "${APPLE_NOTARY_KEY_PATH:?APPLE_NOTARY_KEY_PATH is required}" +: "${APPLE_NOTARY_KEY_ID:?APPLE_NOTARY_KEY_ID is required}" +: "${APPLE_NOTARY_ISSUER_ID:?APPLE_NOTARY_ISSUER_ID is required}" +NOTARY_TIMEOUT="${NOTARY_TIMEOUT:-30m}" + +if [[ ! -f "$DMG_PATH" ]]; then + echo "DMG not found: $DMG_PATH" >&2 + exit 1 +fi +if [[ ! -f "$APPLE_NOTARY_KEY_PATH" ]]; then + echo "App Store Connect API key not found: $APPLE_NOTARY_KEY_PATH" >&2 + exit 1 +fi + +xcrun notarytool submit "$DMG_PATH" \ + --key "$APPLE_NOTARY_KEY_PATH" \ + --key-id "$APPLE_NOTARY_KEY_ID" \ + --issuer "$APPLE_NOTARY_ISSUER_ID" \ + --wait \ + --timeout "$NOTARY_TIMEOUT" +xcrun stapler staple "$DMG_PATH" +xcrun stapler validate "$DMG_PATH" +spctl --assess \ + --type open \ + --context context:primary-signature \ + --verbose=2 \ + "$DMG_PATH" + +echo "Notarized and validated $DMG_PATH" diff --git a/scripts/test_macos_distribution_signing.sh b/scripts/test_macos_distribution_signing.sh new file mode 100755 index 00000000..0a52c6e2 --- /dev/null +++ b/scripts/test_macos_distribution_signing.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/macos_distribution_signing.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +expect_success() { + local description="$1" + shift + "$@" || fail "$description" +} + +expect_failure() { + local description="$1" + shift + if "$@"; then + fail "$description" + fi +} + +adhoc_details=$'Signature=adhoc\nTeamIdentifier=not set' +adhoc_requirement='# designated => cdhash H"d8b56c9110cf6f078cd45f0c4d19876a3ff9b288"' +developer_id_details=$'Authority=Developer ID Application: Example Developer (TEAM123456)\nTeamIdentifier=TEAM123456' +developer_id_requirement='# designated => anchor apple generic and identifier "com.ergohaven.entropy" and certificate leaf[subject.OU] = TEAM123456' + +expect_failure \ + "required distribution signing accepted an ad-hoc identity" \ + macos_validate_signing_configuration "-" "1" +expect_success \ + "local ad-hoc packaging should remain available" \ + macos_validate_signing_configuration "-" "0" +expect_failure \ + "ad-hoc signature passed distribution validation" \ + macos_validate_distribution_signature_output "$adhoc_details" "$adhoc_requirement" +expect_failure \ + "hash-only designated requirement passed distribution validation" \ + macos_validate_distribution_signature_output \ + "$developer_id_details" \ + "$adhoc_requirement" +expect_success \ + "Developer ID signature failed distribution validation" \ + macos_validate_distribution_signature_output \ + "$developer_id_details" \ + "$developer_id_requirement" + +mock_codesign_details="$adhoc_details" +mock_codesign_requirement="$adhoc_requirement" +codesign() { + case "$1" in + --verify) return 0 ;; + -dv) printf '%s\n' "$mock_codesign_details" >&2 ;; + -d) printf '%s\n' "$mock_codesign_requirement" >&2 ;; + *) fail "unexpected mock codesign arguments: $*" ;; + esac +} + +expect_failure \ + "app verification swallowed ad-hoc signature rejection" \ + macos_verify_distribution_signature "/tmp/Entropy.app" +mock_codesign_details="$developer_id_details" +mock_codesign_requirement="$developer_id_requirement" +expect_success \ + "app verification rejected a Developer ID signature" \ + macos_verify_distribution_signature "/tmp/Entropy.app" + +echo "macOS distribution signing tests passed" From 879ca170a56f1071294492a8718fefc6046f57ff Mon Sep 17 00:00:00 2001 From: Igor Arkhipov Date: Sun, 12 Jul 2026 18:50:18 -0700 Subject: [PATCH 2/7] Support stable macOS identity without Developer ID --- .github/macos-release-signing.md | 81 ++++++++++++--- .github/workflows/build.yml | 6 ++ .github/workflows/release.yml | 58 ++++++----- scripts/build_macos_app.sh | 43 ++++---- scripts/macos_distribution_signing.sh | 55 ---------- scripts/macos_stable_signing.sh | 87 ++++++++++++++++ scripts/notarize_macos_dmg.sh | 38 ------- scripts/test_macos_distribution_signing.sh | 71 ------------- scripts/test_macos_stable_identity_e2e.sh | 105 +++++++++++++++++++ scripts/test_macos_stable_signing.sh | 115 +++++++++++++++++++++ 10 files changed, 435 insertions(+), 224 deletions(-) delete mode 100755 scripts/macos_distribution_signing.sh create mode 100755 scripts/macos_stable_signing.sh delete mode 100755 scripts/notarize_macos_dmg.sh delete mode 100755 scripts/test_macos_distribution_signing.sh create mode 100755 scripts/test_macos_stable_identity_e2e.sh create mode 100755 scripts/test_macos_stable_signing.sh diff --git a/.github/macos-release-signing.md b/.github/macos-release-signing.md index af84b99a..5885c6ba 100644 --- a/.github/macos-release-signing.md +++ b/.github/macos-release-signing.md @@ -1,27 +1,84 @@ -# macOS release signing +# macOS stable permission identity prototype -Entropy releases must use one Developer ID Application identity across versions. macOS privacy permissions use code-signing designated requirements to recognize updates as the same app; ad-hoc signatures instead produce a requirement tied to one binary hash. +Entropy currently ships GitHub artifact files without Apple Developer Program membership. Developer ID signing and notarization are therefore unavailable. -Configure these GitHub Actions repository secrets before pushing a `v0.*` tag: +This prototype uses one project-owned self-signed code-signing certificate across releases. Its only goal is a stable, certificate-anchored designated requirement so macOS privacy controls can recognize changed binaries as the same app. + +## Limits + +- Apple explicitly advises against shipping self-signed apps. +- Gatekeeper does not trust this certificate. Users still see an unidentified-developer warning and may need Privacy & Security > Open Anyway. +- Apple notarization remains unavailable. +- TCC persistence must be verified manually on supported macOS versions before this workflow ships. +- Losing or rotating the private key changes app identity and requires users to grant Accessibility and Input Monitoring again. +- A compromised private key lets an attacker sign code that matches Entropy's permission identity. Keep it restricted to release maintainers. + +Do not replace the certificate anchor with a bundle-ID-only requirement. Any binary can copy a bundle ID; Accessibility grants make that unsafe. + +## Create release identity + +Create this identity once on a secure Mac. The common name must remain `Entropy Open Source Release Signing` because the release workflow uses that exact name. + +```bash +umask 077 +openssl req -new -newkey rsa:4096 -x509 -sha256 -nodes \ + -days 3650 \ + -subj '/CN=Entropy Open Source Release Signing/O=Entropy Open Source' \ + -addext 'basicConstraints=critical,CA:TRUE' \ + -addext 'keyUsage=critical,digitalSignature,keyCertSign' \ + -addext 'extendedKeyUsage=codeSigning' \ + -keyout entropy-release-signing.key \ + -out entropy-release-signing.pem +openssl pkcs12 -export \ + -inkey entropy-release-signing.key \ + -in entropy-release-signing.pem \ + -name 'Entropy Open Source Release Signing' \ + -out entropy-release-signing.p12 +``` + +Store encrypted `.p12` and its password in maintainer-controlled offline backup. Delete unencrypted `.key` after backup and GitHub configuration. Never commit private material. + +Configure two GitHub Actions repository secrets: | Secret | Value | | --- | --- | -| `MACOS_CERTIFICATE_P12_BASE64` | Base64-encoded `.p12` containing Developer ID Application certificate and private key | +| `MACOS_CERTIFICATE_P12_BASE64` | `base64 -i entropy-release-signing.p12` output | | `MACOS_CERTIFICATE_PASSWORD` | `.p12` export password | -| `MACOS_SIGNING_IDENTITY` | Full identity name, for example `Developer ID Application: Example Developer (TEAM123456)` | -| `APPLE_NOTARY_KEY_P8_BASE64` | Base64-encoded App Store Connect team API private key | -| `APPLE_NOTARY_KEY_ID` | App Store Connect API key ID | -| `APPLE_NOTARY_ISSUER_ID` | App Store Connect API issuer ID | -The release workflow imports the certificate into a temporary keychain, enables hardened runtime, signs app and DMG, validates that app has stable Developer ID designated requirement, notarizes DMG, staples ticket, and runs Gatekeeper assessment. Missing credentials fail release before artifact upload. +Release workflow imports identity into a temporary keychain, derives certificate hash, embeds an explicit requirement containing that certificate plus `com.ergohaven.entropy`, signs app, and rejects ad-hoc or mismatched output. DMG is not notarized. -First Developer ID-signed release has different identity from old ad-hoc releases. Existing users must remove old Entropy entries from Accessibility and Input Monitoring, add current app again, and grant permissions once. Later releases signed with same Developer ID preserve that identity. +## Automated proof -Inspect built app identity with: +Run: + +```bash +scripts/test_macos_stable_signing.sh +scripts/test_macos_stable_identity_e2e.sh +``` + +End-to-end test creates temporary self-signed identity and two different binaries. Test passes only when code hashes differ while designated requirements match. Temporary keychain and keys are deleted on exit. + +## Required manual TCC test + +1. Produce two Entropy app builds with different binaries and same release identity. +2. Install first build as `/Applications/Entropy.app`. +3. Remove old Entropy entries from Accessibility and Input Monitoring. Add current app, grant both permissions, and verify Universal Symbols. +4. Replace app with second build without changing path. +5. Launch second build. Verify Universal Symbols still work without removing or re-adding permission entries. +6. Repeat on Apple Silicon and Intel machines. + +Inspect both builds: ```bash codesign -dv --verbose=4 Entropy.app codesign -d -r- Entropy.app +spctl --assess --type execute --verbose=4 Entropy.app +``` + +Expected designated requirement: + +```text +designated => certificate root = H"" and identifier "com.ergohaven.entropy" ``` -Expected output includes Developer ID Application authority and Team ID. Designated requirement must not be `cdhash`-only. +`spctl` rejection is expected for this prototype. Any `cdhash`-only requirement, changed certificate hash, or repeated TCC grant invalidates prototype. diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cb9c189e..34823d53 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,6 +92,12 @@ jobs: cargo test --all-targets app::vial_hid_task::tests::macro_write_uses_the_serialized_background_transport -- --exact cargo test --all-targets app::app_lifecycle::tests::layer_completion_starts_settings_queued_behind_it -- --exact + - name: Test macOS stable signing identity + if: matrix.kind == 'macos' + run: | + scripts/test_macos_stable_signing.sh + scripts/test_macos_stable_identity_e2e.sh + - name: Upload artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ab2d7c1b..6bbb24a7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,21 +45,23 @@ jobs: if: matrix.kind == 'macos' run: rustup target add ${{ matrix.target }} - - name: Import macOS Developer ID certificate + - name: Import macOS self-signed release identity if: matrix.kind == 'macos' shell: bash env: CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} + MACOS_SIGNING_IDENTITY: Entropy Open Source Release Signing run: | umask 077 if [[ -z "$CERTIFICATE_P12_BASE64" ]] || [[ -z "$MACOS_CERTIFICATE_PASSWORD" ]]; then - echo "macOS Developer ID certificate secrets are not configured" >&2 + echo "macOS stable signing secrets are not configured" >&2 exit 1 fi - certificate_path="$RUNNER_TEMP/entropy-developer-id.p12" + certificate_path="$RUNNER_TEMP/entropy-release-signing.p12" + certificate_pem_path="$RUNNER_TEMP/entropy-release-signing.pem" keychain_path="$RUNNER_TEMP/entropy-signing.keychain-db" keychain_password="$(openssl rand -base64 32)" @@ -71,7 +73,6 @@ jobs: security import "$certificate_path" \ -P "$MACOS_CERTIFICATE_PASSWORD" \ -A \ - -t cert \ -f pkcs12 \ -k "$keychain_path" security set-key-partition-list \ @@ -81,6 +82,24 @@ jobs: "$keychain_path" security list-keychains -d user -s "$keychain_path" + security find-certificate \ + -c "$MACOS_SIGNING_IDENTITY" \ + -p \ + "$keychain_path" > "$certificate_pem_path" + certificate_sha1="$( + openssl x509 \ + -in "$certificate_pem_path" \ + -noout \ + -fingerprint \ + -sha1 | + sed 's/^.*=//; s/://g' + )" + if [[ ! "$certificate_sha1" =~ ^[[:xdigit:]]{40}$ ]]; then + echo "Could not derive macOS signing certificate SHA-1" >&2 + exit 1 + fi + echo "MACOS_SIGNING_CERTIFICATE_SHA1=$certificate_sha1" >> "$GITHUB_ENV" + - name: Install Linux dependencies if: matrix.os == 'ubuntu-latest' run: | @@ -118,38 +137,21 @@ jobs: if: matrix.kind == 'macos' shell: bash env: - CODESIGN_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }} - REQUIRE_DISTRIBUTION_SIGNING: '1' + CODESIGN_IDENTITY: Entropy Open Source Release Signing + REQUIRE_STABLE_SIGNING: '1' run: | TARGET="${{ matrix.target }}" scripts/build_macos_app.sh - - name: Notarize macOS DMG + - name: Package macOS release artifact if: matrix.kind == 'macos' shell: bash - env: - APPLE_NOTARY_ISSUER_ID: ${{ secrets.APPLE_NOTARY_ISSUER_ID }} - APPLE_NOTARY_KEY_ID: ${{ secrets.APPLE_NOTARY_KEY_ID }} - APPLE_NOTARY_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_KEY_P8_BASE64 }} run: | - umask 077 - if [[ -z "$APPLE_NOTARY_KEY_P8_BASE64" ]]; then - echo "Apple notarization key secret is not configured" >&2 - exit 1 - fi - - notary_key_path="$RUNNER_TEMP/AuthKey_${APPLE_NOTARY_KEY_ID}.p8" - printf '%s' "$APPLE_NOTARY_KEY_P8_BASE64" | - base64 -D > "$notary_key_path" - chmod 600 "$notary_key_path" - dmg_paths=(dist/macos/*.dmg) if [[ ${#dmg_paths[@]} -ne 1 || ! -f "${dmg_paths[0]}" ]]; then echo "Expected exactly one macOS DMG" >&2 exit 1 fi - APPLE_NOTARY_KEY_PATH="$notary_key_path" \ - scripts/notarize_macos_dmg.sh "${dmg_paths[0]}" mkdir -p dist/release asset="entropy-${GITHUB_REF_NAME}-macos-${{ matrix.arch }}.dmg" cp "${dmg_paths[0]}" "dist/release/$asset" @@ -161,8 +163,8 @@ jobs: keychain_path="$RUNNER_TEMP/entropy-signing.keychain-db" security delete-keychain "$keychain_path" || true rm -f \ - "$RUNNER_TEMP/entropy-developer-id.p12" \ - "$RUNNER_TEMP"/AuthKey_*.p8 + "$RUNNER_TEMP/entropy-release-signing.p12" \ + "$RUNNER_TEMP/entropy-release-signing.pem" - name: Upload artifact uses: actions/upload-artifact@v4 @@ -198,8 +200,8 @@ jobs: printf '\n## Downloads\n\n' >> RELEASE_NOTES.md printf '%s\n' '- Linux: download the `.AppImage`, make it executable, and run it.' >> RELEASE_NOTES.md printf '%s\n' '- Windows: download and run the portable `.exe`.' >> RELEASE_NOTES.md - printf '%s\n' '- macOS: download the signed and notarized `.dmg` for your Mac (`arm64` for Apple Silicon, `x86_64` for Intel), then drag Entropy to Applications.' >> RELEASE_NOTES.md - printf '%s\n' '- When upgrading from Entropy v0.2.0 or earlier, remove old Entropy entries from Accessibility and Input Monitoring, then grant both permissions once for the signed app. Later signed updates preserve this identity.' >> RELEASE_NOTES.md + printf '%s\n' '- macOS: download the project-signed, non-notarized `.dmg` for your Mac (`arm64` for Apple Silicon, `x86_64` for Intel), then drag Entropy to Applications. macOS may require Privacy & Security > Open Anyway.' >> RELEASE_NOTES.md + printf '%s\n' '- When upgrading from Entropy v0.2.0 or earlier, remove old Entropy entries from Accessibility and Input Monitoring, then grant both permissions once for the project-signed app. Later builds signed with the same project key are intended to preserve this identity.' >> RELEASE_NOTES.md - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/scripts/build_macos_app.sh b/scripts/build_macos_app.sh index 087081bd..d8c92dd8 100755 --- a/scripts/build_macos_app.sh +++ b/scripts/build_macos_app.sh @@ -5,11 +5,12 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_NAME="${APP_NAME:-Entropy}" BUNDLE_ID="${BUNDLE_ID:-com.ergohaven.entropy}" CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}" -REQUIRE_DISTRIBUTION_SIGNING="${REQUIRE_DISTRIBUTION_SIGNING:-0}" +MACOS_SIGNING_CERTIFICATE_SHA1="${MACOS_SIGNING_CERTIFICATE_SHA1:-}" +REQUIRE_STABLE_SIGNING="${REQUIRE_STABLE_SIGNING:-0}" MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-10.15}" export MACOSX_DEPLOYMENT_TARGET -source "$ROOT/scripts/macos_distribution_signing.sh" +source "$ROOT/scripts/macos_stable_signing.sh" VERSION="$( awk -F '"' '/^version = / { print $2; exit }' "$ROOT/Cargo.toml" @@ -84,7 +85,7 @@ sign_app_bundle() { fi if ! command -v codesign >/dev/null 2>&1; then - if [[ "$REQUIRE_DISTRIBUTION_SIGNING" == "1" ]]; then + if [[ "$REQUIRE_STABLE_SIGNING" == "1" ]]; then echo "codesign not found; cannot build a signed release" >&2 return 1 fi @@ -96,26 +97,27 @@ sign_app_bundle() { if [[ "$CODESIGN_IDENTITY" == "-" ]]; then codesign_args+=(--timestamp=none) else - codesign_args+=(--options runtime --timestamp) + codesign_args+=( + --identifier "$BUNDLE_ID" + --requirements "$(macos_stable_designated_requirement \ + "$MACOS_SIGNING_CERTIFICATE_SHA1" \ + "$BUNDLE_ID")" + --options runtime + --timestamp=none + ) fi codesign "${codesign_args[@]}" "$APP_PATH" - if [[ "$REQUIRE_DISTRIBUTION_SIGNING" == "1" ]]; then - macos_verify_distribution_signature "$APP_PATH" + if [[ "$REQUIRE_STABLE_SIGNING" == "1" ]]; then + macos_verify_stable_signature \ + "$APP_PATH" \ + "$MACOS_SIGNING_CERTIFICATE_SHA1" \ + "$BUNDLE_ID" else codesign --verify --strict "$APP_PATH" fi } -sign_disk_image() { - if [[ "$CODESIGN_IDENTITY" == "-" ]]; then - return - fi - - codesign --force --timestamp --sign "$CODESIGN_IDENTITY" "$DMG_PATH" - codesign --verify --verbose=2 "$DMG_PATH" -} - create_dmg_with_retries() { local max_attempts=3 local attempt=1 @@ -147,9 +149,11 @@ create_dmg_with_retries() { return "$status" } -macos_validate_signing_configuration \ +macos_validate_stable_signing_configuration \ "$CODESIGN_IDENTITY" \ - "$REQUIRE_DISTRIBUTION_SIGNING" + "$REQUIRE_STABLE_SIGNING" \ + "$MACOS_SIGNING_CERTIFICATE_SHA1" \ + "$BUNDLE_ID" cd "$ROOT" cargo build "${BUILD_ARGS[@]}" @@ -214,9 +218,8 @@ fi if command -v hdiutil >/dev/null 2>&1; then create_dmg_with_retries - sign_disk_image -elif [[ "$REQUIRE_DISTRIBUTION_SIGNING" == "1" ]]; then - echo "hdiutil not found; cannot build a signed release DMG" >&2 +elif [[ "$REQUIRE_STABLE_SIGNING" == "1" ]]; then + echo "hdiutil not found; cannot build a macOS release DMG" >&2 exit 1 else echo "hdiutil not found; skipped DMG build" diff --git a/scripts/macos_distribution_signing.sh b/scripts/macos_distribution_signing.sh deleted file mode 100755 index ab80d924..00000000 --- a/scripts/macos_distribution_signing.sh +++ /dev/null @@ -1,55 +0,0 @@ -#!/usr/bin/env bash - -macos_validate_signing_configuration() { - local identity="${1:-}" - local required="${2:-0}" - - if [[ "$required" != "0" && "$required" != "1" ]]; then - echo "REQUIRE_DISTRIBUTION_SIGNING must be 0 or 1" >&2 - return 1 - fi - - if [[ "$required" == "1" && ( -z "$identity" || "$identity" == "-" ) ]]; then - echo "Developer ID signing is required; refusing to build an ad-hoc release" >&2 - return 1 - fi -} - -macos_validate_distribution_signature_output() { - local details="$1" - local requirement="$2" - - if [[ "$details" == *"Signature=adhoc"* ]]; then - echo "Distribution app is ad-hoc signed" >&2 - return 1 - fi - if [[ "$details" != *"TeamIdentifier="* ]] || - [[ "$details" == *"TeamIdentifier=not set"* ]]; then - echo "Distribution app has no Team ID" >&2 - return 1 - fi - if [[ "$details" != *"Authority=Developer ID Application:"* ]]; then - echo "Distribution app is not signed with a Developer ID Application certificate" >&2 - return 1 - fi - if [[ "$requirement" != *"# designated =>"* ]]; then - echo "Distribution app has no designated requirement" >&2 - return 1 - fi - if [[ "$requirement" == *"# designated => cdhash "* ]]; then - echo "Distribution app designated requirement is tied to one binary hash" >&2 - return 1 - fi -} - -macos_verify_distribution_signature() { - local app_path="$1" - local details - local requirement - - codesign --verify --deep --strict --verbose=2 "$app_path" || return 1 - details="$(codesign -dv --verbose=4 "$app_path" 2>&1)" || return 1 - requirement="$(codesign -d -r- "$app_path" 2>&1)" || return 1 - macos_validate_distribution_signature_output "$details" "$requirement" || return 1 - echo "Validated stable Developer ID signature for $app_path" -} diff --git a/scripts/macos_stable_signing.sh b/scripts/macos_stable_signing.sh new file mode 100755 index 00000000..e38d2412 --- /dev/null +++ b/scripts/macos_stable_signing.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash + +macos_stable_designated_requirement() { + local certificate_sha1="${1:-}" + local bundle_id="${2:-}" + local normalized_sha1 + + normalized_sha1="$(printf '%s' "$certificate_sha1" | tr '[:upper:]' '[:lower:]')" + + printf '=designated => anchor = H"%s" and identifier "%s"\n' \ + "$normalized_sha1" \ + "$bundle_id" +} + +macos_validate_stable_signing_configuration() { + local identity="${1:-}" + local required="${2:-0}" + local certificate_sha1="${3:-}" + local bundle_id="${4:-}" + + if [[ "$required" != "0" && "$required" != "1" ]]; then + echo "REQUIRE_STABLE_SIGNING must be 0 or 1" >&2 + return 1 + fi + + if [[ "$required" == "1" && ( -z "$identity" || "$identity" == "-" ) ]]; then + echo "Stable signing is required; refusing to build an ad-hoc release" >&2 + return 1 + fi + + if [[ -n "$identity" && "$identity" != "-" ]]; then + if [[ ! "$certificate_sha1" =~ ^[[:xdigit:]]{40}$ ]]; then + echo "MACOS_SIGNING_CERTIFICATE_SHA1 must contain 40 hexadecimal characters" >&2 + return 1 + fi + if [[ ! "$bundle_id" =~ ^[[:alnum:].-]+$ ]]; then + echo "BUNDLE_ID contains unsupported characters" >&2 + return 1 + fi + fi +} + +macos_validate_stable_signature_output() { + local details="$1" + local requirement="$2" + local certificate_sha1="$3" + local bundle_id="$4" + local expected_requirement + + certificate_sha1="$(printf '%s' "$certificate_sha1" | tr '[:upper:]' '[:lower:]')" + expected_requirement="designated => certificate root = H\"$certificate_sha1\" and identifier \"$bundle_id\"" + + if [[ "$details" == *"Signature=adhoc"* ]]; then + echo "Release app is ad-hoc signed" >&2 + return 1 + fi + if [[ "$details" != *"Authority="* ]]; then + echo "Release app has no certificate authority" >&2 + return 1 + fi + if [[ "$requirement" == *"cdhash "* ]]; then + echo "Release app designated requirement is tied to one binary hash" >&2 + return 1 + fi + if [[ "$requirement" != *"$expected_requirement"* ]]; then + echo "Release app designated requirement does not match expected certificate and bundle ID" >&2 + return 1 + fi +} + +macos_verify_stable_signature() { + local app_path="$1" + local certificate_sha1="$2" + local bundle_id="$3" + local details + local requirement + + codesign --verify --deep --strict --verbose=2 "$app_path" || return 1 + details="$(codesign -dv --verbose=4 "$app_path" 2>&1)" || return 1 + requirement="$(codesign -d -r- "$app_path" 2>&1)" || return 1 + macos_validate_stable_signature_output \ + "$details" \ + "$requirement" \ + "$certificate_sha1" \ + "$bundle_id" || return 1 + echo "Validated stable certificate-anchored signature for $app_path" +} diff --git a/scripts/notarize_macos_dmg.sh b/scripts/notarize_macos_dmg.sh deleted file mode 100755 index 9272620f..00000000 --- a/scripts/notarize_macos_dmg.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -if [[ $# -ne 1 ]]; then - echo "Usage: $0 " >&2 - exit 2 -fi - -DMG_PATH="$1" -: "${APPLE_NOTARY_KEY_PATH:?APPLE_NOTARY_KEY_PATH is required}" -: "${APPLE_NOTARY_KEY_ID:?APPLE_NOTARY_KEY_ID is required}" -: "${APPLE_NOTARY_ISSUER_ID:?APPLE_NOTARY_ISSUER_ID is required}" -NOTARY_TIMEOUT="${NOTARY_TIMEOUT:-30m}" - -if [[ ! -f "$DMG_PATH" ]]; then - echo "DMG not found: $DMG_PATH" >&2 - exit 1 -fi -if [[ ! -f "$APPLE_NOTARY_KEY_PATH" ]]; then - echo "App Store Connect API key not found: $APPLE_NOTARY_KEY_PATH" >&2 - exit 1 -fi - -xcrun notarytool submit "$DMG_PATH" \ - --key "$APPLE_NOTARY_KEY_PATH" \ - --key-id "$APPLE_NOTARY_KEY_ID" \ - --issuer "$APPLE_NOTARY_ISSUER_ID" \ - --wait \ - --timeout "$NOTARY_TIMEOUT" -xcrun stapler staple "$DMG_PATH" -xcrun stapler validate "$DMG_PATH" -spctl --assess \ - --type open \ - --context context:primary-signature \ - --verbose=2 \ - "$DMG_PATH" - -echo "Notarized and validated $DMG_PATH" diff --git a/scripts/test_macos_distribution_signing.sh b/scripts/test_macos_distribution_signing.sh deleted file mode 100755 index 0a52c6e2..00000000 --- a/scripts/test_macos_distribution_signing.sh +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -source "$ROOT/scripts/macos_distribution_signing.sh" - -fail() { - echo "FAIL: $*" >&2 - exit 1 -} - -expect_success() { - local description="$1" - shift - "$@" || fail "$description" -} - -expect_failure() { - local description="$1" - shift - if "$@"; then - fail "$description" - fi -} - -adhoc_details=$'Signature=adhoc\nTeamIdentifier=not set' -adhoc_requirement='# designated => cdhash H"d8b56c9110cf6f078cd45f0c4d19876a3ff9b288"' -developer_id_details=$'Authority=Developer ID Application: Example Developer (TEAM123456)\nTeamIdentifier=TEAM123456' -developer_id_requirement='# designated => anchor apple generic and identifier "com.ergohaven.entropy" and certificate leaf[subject.OU] = TEAM123456' - -expect_failure \ - "required distribution signing accepted an ad-hoc identity" \ - macos_validate_signing_configuration "-" "1" -expect_success \ - "local ad-hoc packaging should remain available" \ - macos_validate_signing_configuration "-" "0" -expect_failure \ - "ad-hoc signature passed distribution validation" \ - macos_validate_distribution_signature_output "$adhoc_details" "$adhoc_requirement" -expect_failure \ - "hash-only designated requirement passed distribution validation" \ - macos_validate_distribution_signature_output \ - "$developer_id_details" \ - "$adhoc_requirement" -expect_success \ - "Developer ID signature failed distribution validation" \ - macos_validate_distribution_signature_output \ - "$developer_id_details" \ - "$developer_id_requirement" - -mock_codesign_details="$adhoc_details" -mock_codesign_requirement="$adhoc_requirement" -codesign() { - case "$1" in - --verify) return 0 ;; - -dv) printf '%s\n' "$mock_codesign_details" >&2 ;; - -d) printf '%s\n' "$mock_codesign_requirement" >&2 ;; - *) fail "unexpected mock codesign arguments: $*" ;; - esac -} - -expect_failure \ - "app verification swallowed ad-hoc signature rejection" \ - macos_verify_distribution_signature "/tmp/Entropy.app" -mock_codesign_details="$developer_id_details" -mock_codesign_requirement="$developer_id_requirement" -expect_success \ - "app verification rejected a Developer ID signature" \ - macos_verify_distribution_signature "/tmp/Entropy.app" - -echo "macOS distribution signing tests passed" diff --git a/scripts/test_macos_stable_identity_e2e.sh b/scripts/test_macos_stable_identity_e2e.sh new file mode 100755 index 00000000..ed261d55 --- /dev/null +++ b/scripts/test_macos_stable_identity_e2e.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$(uname -s)" != "Darwin" ]]; then + echo "Skipped macOS stable identity end-to-end test" + exit 0 +fi + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/macos_stable_signing.sh" + +TMP_DIR="$(mktemp -d /tmp/entropy-stable-signing.XXXXXX)" +KEYCHAIN_PATH="$TMP_DIR/test.keychain-db" +KEYCHAIN_PASSWORD="entropy-test-keychain" +P12_PASSWORD="entropy-test-p12" +IDENTITY="Entropy Open Source Release Signing" +BUNDLE_ID="com.ergohaven.entropy" +ORIGINAL_KEYCHAINS=() + +while IFS= read -r keychain; do + keychain="${keychain//\"/}" + ORIGINAL_KEYCHAINS+=("$keychain") +done < <(security list-keychains -d user) + +cleanup() { + security list-keychains -d user -s "${ORIGINAL_KEYCHAINS[@]}" >/dev/null 2>&1 || true + security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +openssl req -new -newkey rsa:2048 -x509 -sha256 -nodes \ + -days 30 \ + -subj "/CN=$IDENTITY/O=Entropy Open Source" \ + -addext "basicConstraints=critical,CA:TRUE" \ + -addext "keyUsage=critical,digitalSignature,keyCertSign" \ + -addext "extendedKeyUsage=codeSigning" \ + -keyout "$TMP_DIR/key.pem" \ + -out "$TMP_DIR/cert.pem" >/dev/null 2>&1 +openssl pkcs12 -export \ + -inkey "$TMP_DIR/key.pem" \ + -in "$TMP_DIR/cert.pem" \ + -name "$IDENTITY" \ + -passout "pass:$P12_PASSWORD" \ + -out "$TMP_DIR/identity.p12" + +security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" +security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" +security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" +security import "$TMP_DIR/identity.p12" \ + -P "$P12_PASSWORD" \ + -A \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" >/dev/null +security set-key-partition-list \ + -S apple-tool:,apple:,codesign: \ + -s \ + -k "$KEYCHAIN_PASSWORD" \ + "$KEYCHAIN_PATH" >/dev/null +security list-keychains -d user -s "$KEYCHAIN_PATH" "${ORIGINAL_KEYCHAINS[@]}" + +security find-certificate \ + -c "$IDENTITY" \ + -p \ + "$KEYCHAIN_PATH" > "$TMP_DIR/imported-cert.pem" + +printf '%s\n' 'int main(void) { return 1; }' | + clang -x c - -o "$TMP_DIR/entropy-v1" +printf '%s\n' 'int main(void) { return 2; }' | + clang -x c - -o "$TMP_DIR/entropy-v2" + +CERTIFICATE_SHA1="$( + openssl x509 -in "$TMP_DIR/imported-cert.pem" -noout -fingerprint -sha1 | + sed 's/^.*=//; s/://g' +)" +REQUIREMENT="$(macos_stable_designated_requirement "$CERTIFICATE_SHA1" "$BUNDLE_ID")" + +for binary in "$TMP_DIR/entropy-v1" "$TMP_DIR/entropy-v2"; do + codesign --force \ + --sign "$IDENTITY" \ + --keychain "$KEYCHAIN_PATH" \ + --identifier "$BUNDLE_ID" \ + --requirements "$REQUIREMENT" \ + --options runtime \ + --timestamp=none \ + "$binary" + macos_verify_stable_signature "$binary" "$CERTIFICATE_SHA1" "$BUNDLE_ID" +done + +requirement_v1="$(codesign -d -r- "$TMP_DIR/entropy-v1" 2>&1 | sed -n '/^designated =>/p')" +requirement_v2="$(codesign -d -r- "$TMP_DIR/entropy-v2" 2>&1 | sed -n '/^designated =>/p')" +cdhash_v1="$(codesign -dvvv "$TMP_DIR/entropy-v1" 2>&1 | awk -F= '/^CDHash=/{print $2}')" +cdhash_v2="$(codesign -dvvv "$TMP_DIR/entropy-v2" 2>&1 | awk -F= '/^CDHash=/{print $2}')" + +if [[ "$requirement_v1" != "$requirement_v2" ]]; then + echo "Different builds produced different designated requirements" >&2 + exit 1 +fi +if [[ -z "$cdhash_v1" || -z "$cdhash_v2" || "$cdhash_v1" == "$cdhash_v2" ]]; then + echo "Test binaries did not produce distinct code hashes" >&2 + exit 1 +fi + +echo "$requirement_v1" +echo "macOS stable identity end-to-end test passed" diff --git a/scripts/test_macos_stable_signing.sh b/scripts/test_macos_stable_signing.sh new file mode 100755 index 00000000..438bafde --- /dev/null +++ b/scripts/test_macos_stable_signing.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source "$ROOT/scripts/macos_stable_signing.sh" + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +expect_success() { + local description="$1" + shift + "$@" || fail "$description" +} + +expect_failure() { + local description="$1" + shift + if "$@"; then + fail "$description" + fi +} + +adhoc_details=$'Signature=adhoc\nTeamIdentifier=not set' +adhoc_requirement='designated => cdhash H"d8b56c9110cf6f078cd45f0c4d19876a3ff9b288"' +certificate_sha1='0123456789abcdef0123456789abcdef01234567' +bundle_id='com.ergohaven.entropy' +self_signed_details=$'Authority=Entropy Open Source Release Signing\nTeamIdentifier=not set' +self_signed_requirement="designated => certificate root = H\"$certificate_sha1\" and identifier \"$bundle_id\"" + +expect_failure \ + "required stable signing accepted an ad-hoc identity" \ + macos_validate_stable_signing_configuration "-" "1" "" "$bundle_id" +expect_success \ + "local ad-hoc packaging should remain available" \ + macos_validate_stable_signing_configuration "-" "0" "" "$bundle_id" +expect_failure \ + "stable signing accepted a missing certificate hash" \ + macos_validate_stable_signing_configuration \ + "Entropy Open Source Release Signing" \ + "1" \ + "" \ + "$bundle_id" +expect_failure \ + "stable signing accepted an invalid certificate hash" \ + macos_validate_stable_signing_configuration \ + "Entropy Open Source Release Signing" \ + "1" \ + "invalid" \ + "$bundle_id" +expect_success \ + "valid stable signing configuration failed" \ + macos_validate_stable_signing_configuration \ + "Entropy Open Source Release Signing" \ + "1" \ + "$certificate_sha1" \ + "$bundle_id" +expect_failure \ + "ad-hoc signature passed stable validation" \ + macos_validate_stable_signature_output \ + "$adhoc_details" \ + "$adhoc_requirement" \ + "$certificate_sha1" \ + "$bundle_id" +expect_failure \ + "hash-only designated requirement passed stable validation" \ + macos_validate_stable_signature_output \ + "$self_signed_details" \ + "$adhoc_requirement" \ + "$certificate_sha1" \ + "$bundle_id" +expect_failure \ + "wrong certificate passed stable validation" \ + macos_validate_stable_signature_output \ + "$self_signed_details" \ + "$self_signed_requirement" \ + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \ + "$bundle_id" +expect_success \ + "self-signed certificate requirement failed stable validation" \ + macos_validate_stable_signature_output \ + "$self_signed_details" \ + "$self_signed_requirement" \ + "$certificate_sha1" \ + "$bundle_id" + +mock_codesign_details="$adhoc_details" +mock_codesign_requirement="$adhoc_requirement" +codesign() { + case "$1" in + --verify) return 0 ;; + -dv) printf '%s\n' "$mock_codesign_details" >&2 ;; + -d) printf '%s\n' "$mock_codesign_requirement" >&2 ;; + *) fail "unexpected mock codesign arguments: $*" ;; + esac +} + +expect_failure \ + "app verification swallowed ad-hoc signature rejection" \ + macos_verify_stable_signature \ + "/tmp/Entropy.app" \ + "$certificate_sha1" \ + "$bundle_id" +mock_codesign_details="$self_signed_details" +mock_codesign_requirement="$self_signed_requirement" +expect_success \ + "app verification rejected a stable self-signed signature" \ + macos_verify_stable_signature \ + "/tmp/Entropy.app" \ + "$certificate_sha1" \ + "$bundle_id" + +echo "macOS stable signing unit tests passed" From 06f9c2543815c0bdfc7e217047418895f925c228 Mon Sep 17 00:00:00 2001 From: Igor Arkhipov Date: Sun, 12 Jul 2026 18:54:40 -0700 Subject: [PATCH 3/7] Read signing certificate directly from P12 --- .github/workflows/release.yml | 9 +++++---- scripts/test_macos_stable_identity_e2e.sh | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bbb24a7..4a83ef85 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -82,10 +82,11 @@ jobs: "$keychain_path" security list-keychains -d user -s "$keychain_path" - security find-certificate \ - -c "$MACOS_SIGNING_IDENTITY" \ - -p \ - "$keychain_path" > "$certificate_pem_path" + openssl pkcs12 \ + -in "$certificate_path" \ + -nokeys \ + -passin "pass:$MACOS_CERTIFICATE_PASSWORD" \ + -out "$certificate_pem_path" certificate_sha1="$( openssl x509 \ -in "$certificate_pem_path" \ diff --git a/scripts/test_macos_stable_identity_e2e.sh b/scripts/test_macos_stable_identity_e2e.sh index ed261d55..29e27d49 100755 --- a/scripts/test_macos_stable_identity_e2e.sh +++ b/scripts/test_macos_stable_identity_e2e.sh @@ -59,10 +59,11 @@ security set-key-partition-list \ "$KEYCHAIN_PATH" >/dev/null security list-keychains -d user -s "$KEYCHAIN_PATH" "${ORIGINAL_KEYCHAINS[@]}" -security find-certificate \ - -c "$IDENTITY" \ - -p \ - "$KEYCHAIN_PATH" > "$TMP_DIR/imported-cert.pem" +openssl pkcs12 \ + -in "$TMP_DIR/identity.p12" \ + -nokeys \ + -passin "pass:$P12_PASSWORD" \ + -out "$TMP_DIR/imported-cert.pem" printf '%s\n' 'int main(void) { return 1; }' | clang -x c - -o "$TMP_DIR/entropy-v1" From 84f5fbac7ce5810b74eb490d96617bafeabbe2e9 Mon Sep 17 00:00:00 2001 From: Igor Arkhipov Date: Sun, 12 Jul 2026 19:01:34 -0700 Subject: [PATCH 4/7] Trust self-signed identity on CI runners --- .github/macos-release-signing.md | 2 +- .github/workflows/release.yml | 13 +++++++++ scripts/build_macos_app.sh | 4 +++ scripts/test_macos_stable_identity_e2e.sh | 35 ++++++++++++++++++++++- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/.github/macos-release-signing.md b/.github/macos-release-signing.md index 5885c6ba..00e7bafd 100644 --- a/.github/macos-release-signing.md +++ b/.github/macos-release-signing.md @@ -45,7 +45,7 @@ Configure two GitHub Actions repository secrets: | `MACOS_CERTIFICATE_P12_BASE64` | `base64 -i entropy-release-signing.p12` output | | `MACOS_CERTIFICATE_PASSWORD` | `.p12` export password | -Release workflow imports identity into a temporary keychain, derives certificate hash, embeds an explicit requirement containing that certificate plus `com.ergohaven.entropy`, signs app, and rejects ad-hoc or mismatched output. DMG is not notarized. +Release workflow imports identity into a temporary keychain, trusts it for code signing on the ephemeral runner, derives certificate hash, embeds an explicit requirement containing that certificate plus `com.ergohaven.entropy`, signs app, removes runner trust, and rejects ad-hoc or mismatched output. DMG is not notarized. Shipped requirement does not contain `anchor trusted`; users do not install or trust this certificate. ## Automated proof diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4a83ef85..8657d5ad 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -99,6 +99,12 @@ jobs: echo "Could not derive macOS signing certificate SHA-1" >&2 exit 1 fi + sudo security add-trusted-cert \ + -d \ + -r trustRoot \ + -p codeSign \ + -k /Library/Keychains/System.keychain \ + "$certificate_pem_path" echo "MACOS_SIGNING_CERTIFICATE_SHA1=$certificate_sha1" >> "$GITHUB_ENV" - name: Install Linux dependencies @@ -139,6 +145,7 @@ jobs: shell: bash env: CODESIGN_IDENTITY: Entropy Open Source Release Signing + CODESIGN_KEYCHAIN: ${{ runner.temp }}/entropy-signing.keychain-db REQUIRE_STABLE_SIGNING: '1' run: | TARGET="${{ matrix.target }}" scripts/build_macos_app.sh @@ -162,6 +169,12 @@ jobs: shell: bash run: | keychain_path="$RUNNER_TEMP/entropy-signing.keychain-db" + certificate_pem_path="$RUNNER_TEMP/entropy-release-signing.pem" + if [[ -f "$certificate_pem_path" ]]; then + sudo security remove-trusted-cert \ + -d \ + "$certificate_pem_path" || true + fi security delete-keychain "$keychain_path" || true rm -f \ "$RUNNER_TEMP/entropy-release-signing.p12" \ diff --git a/scripts/build_macos_app.sh b/scripts/build_macos_app.sh index d8c92dd8..064d36e8 100755 --- a/scripts/build_macos_app.sh +++ b/scripts/build_macos_app.sh @@ -5,6 +5,7 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP_NAME="${APP_NAME:-Entropy}" BUNDLE_ID="${BUNDLE_ID:-com.ergohaven.entropy}" CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}" +CODESIGN_KEYCHAIN="${CODESIGN_KEYCHAIN:-}" MACOS_SIGNING_CERTIFICATE_SHA1="${MACOS_SIGNING_CERTIFICATE_SHA1:-}" REQUIRE_STABLE_SIGNING="${REQUIRE_STABLE_SIGNING:-0}" MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-10.15}" @@ -94,6 +95,9 @@ sign_app_bundle() { fi local codesign_args=(--force --sign "$CODESIGN_IDENTITY") + if [[ -n "$CODESIGN_KEYCHAIN" ]]; then + codesign_args+=(--keychain "$CODESIGN_KEYCHAIN") + fi if [[ "$CODESIGN_IDENTITY" == "-" ]]; then codesign_args+=(--timestamp=none) else diff --git a/scripts/test_macos_stable_identity_e2e.sh b/scripts/test_macos_stable_identity_e2e.sh index 29e27d49..656e4fd6 100755 --- a/scripts/test_macos_stable_identity_e2e.sh +++ b/scripts/test_macos_stable_identity_e2e.sh @@ -16,6 +16,7 @@ P12_PASSWORD="entropy-test-p12" IDENTITY="Entropy Open Source Release Signing" BUNDLE_ID="com.ergohaven.entropy" ORIGINAL_KEYCHAINS=() +SYSTEM_TRUST_ADDED=0 while IFS= read -r keychain; do keychain="${keychain//\"/}" @@ -23,6 +24,11 @@ while IFS= read -r keychain; do done < <(security list-keychains -d user) cleanup() { + if [[ "$SYSTEM_TRUST_ADDED" == "1" ]]; then + sudo -n security remove-trusted-cert \ + -d \ + "$TMP_DIR/imported-cert.pem" >/dev/null 2>&1 || true + fi security list-keychains -d user -s "${ORIGINAL_KEYCHAINS[@]}" >/dev/null 2>&1 || true security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true rm -rf "$TMP_DIR" @@ -76,7 +82,9 @@ CERTIFICATE_SHA1="$( )" REQUIREMENT="$(macos_stable_designated_requirement "$CERTIFICATE_SHA1" "$BUNDLE_ID")" -for binary in "$TMP_DIR/entropy-v1" "$TMP_DIR/entropy-v2"; do +sign_test_binary() { + local binary="$1" + codesign --force \ --sign "$IDENTITY" \ --keychain "$KEYCHAIN_PATH" \ @@ -85,6 +93,31 @@ for binary in "$TMP_DIR/entropy-v1" "$TMP_DIR/entropy-v2"; do --options runtime \ --timestamp=none \ "$binary" +} + +if ! sign_test_binary "$TMP_DIR/entropy-v1"; then + if ! sudo -n security add-trusted-cert \ + -d \ + -r trustRoot \ + -p codeSign \ + -k /Library/Keychains/System.keychain \ + "$TMP_DIR/imported-cert.pem"; then + echo "Self-signed identity failed signing and runner trust could not be added" >&2 + exit 1 + fi + SYSTEM_TRUST_ADDED=1 + sign_test_binary "$TMP_DIR/entropy-v1" +fi +macos_verify_stable_signature \ + "$TMP_DIR/entropy-v1" \ + "$CERTIFICATE_SHA1" \ + "$BUNDLE_ID" + +for binary in "$TMP_DIR/entropy-v1" "$TMP_DIR/entropy-v2"; do + if [[ "$binary" == "$TMP_DIR/entropy-v1" ]]; then + continue + fi + sign_test_binary "$binary" macos_verify_stable_signature "$binary" "$CERTIFICATE_SHA1" "$BUNDLE_ID" done From 2e1fb0385212343746136fbe40074968b633dd36 Mon Sep 17 00:00:00 2001 From: Igor Arkhipov Date: Sun, 12 Jul 2026 19:05:51 -0700 Subject: [PATCH 5/7] Use DER certificate for CI trust --- .github/workflows/release.yml | 16 +++++++++++----- scripts/test_macos_stable_identity_e2e.sh | 8 ++++++-- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8657d5ad..d488036f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,6 +62,7 @@ jobs: certificate_path="$RUNNER_TEMP/entropy-release-signing.p12" certificate_pem_path="$RUNNER_TEMP/entropy-release-signing.pem" + certificate_der_path="$RUNNER_TEMP/entropy-release-signing.cer" keychain_path="$RUNNER_TEMP/entropy-signing.keychain-db" keychain_password="$(openssl rand -base64 32)" @@ -87,6 +88,10 @@ jobs: -nokeys \ -passin "pass:$MACOS_CERTIFICATE_PASSWORD" \ -out "$certificate_pem_path" + openssl x509 \ + -in "$certificate_pem_path" \ + -outform DER \ + -out "$certificate_der_path" certificate_sha1="$( openssl x509 \ -in "$certificate_pem_path" \ @@ -104,7 +109,7 @@ jobs: -r trustRoot \ -p codeSign \ -k /Library/Keychains/System.keychain \ - "$certificate_pem_path" + "$certificate_der_path" echo "MACOS_SIGNING_CERTIFICATE_SHA1=$certificate_sha1" >> "$GITHUB_ENV" - name: Install Linux dependencies @@ -169,16 +174,17 @@ jobs: shell: bash run: | keychain_path="$RUNNER_TEMP/entropy-signing.keychain-db" - certificate_pem_path="$RUNNER_TEMP/entropy-release-signing.pem" - if [[ -f "$certificate_pem_path" ]]; then + certificate_der_path="$RUNNER_TEMP/entropy-release-signing.cer" + if [[ -f "$certificate_der_path" ]]; then sudo security remove-trusted-cert \ -d \ - "$certificate_pem_path" || true + "$certificate_der_path" || true fi security delete-keychain "$keychain_path" || true rm -f \ "$RUNNER_TEMP/entropy-release-signing.p12" \ - "$RUNNER_TEMP/entropy-release-signing.pem" + "$RUNNER_TEMP/entropy-release-signing.pem" \ + "$RUNNER_TEMP/entropy-release-signing.cer" - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/scripts/test_macos_stable_identity_e2e.sh b/scripts/test_macos_stable_identity_e2e.sh index 656e4fd6..9a69fd85 100755 --- a/scripts/test_macos_stable_identity_e2e.sh +++ b/scripts/test_macos_stable_identity_e2e.sh @@ -27,7 +27,7 @@ cleanup() { if [[ "$SYSTEM_TRUST_ADDED" == "1" ]]; then sudo -n security remove-trusted-cert \ -d \ - "$TMP_DIR/imported-cert.pem" >/dev/null 2>&1 || true + "$TMP_DIR/imported-cert.cer" >/dev/null 2>&1 || true fi security list-keychains -d user -s "${ORIGINAL_KEYCHAINS[@]}" >/dev/null 2>&1 || true security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true @@ -70,6 +70,10 @@ openssl pkcs12 \ -nokeys \ -passin "pass:$P12_PASSWORD" \ -out "$TMP_DIR/imported-cert.pem" +openssl x509 \ + -in "$TMP_DIR/imported-cert.pem" \ + -outform DER \ + -out "$TMP_DIR/imported-cert.cer" printf '%s\n' 'int main(void) { return 1; }' | clang -x c - -o "$TMP_DIR/entropy-v1" @@ -101,7 +105,7 @@ if ! sign_test_binary "$TMP_DIR/entropy-v1"; then -r trustRoot \ -p codeSign \ -k /Library/Keychains/System.keychain \ - "$TMP_DIR/imported-cert.pem"; then + "$TMP_DIR/imported-cert.cer"; then echo "Self-signed identity failed signing and runner trust could not be added" >&2 exit 1 fi From a12fbbf38ac9a4850bde274e43ff52a323ed6f5c Mon Sep 17 00:00:00 2001 From: Igor Arkhipov Date: Sun, 12 Jul 2026 19:11:57 -0700 Subject: [PATCH 6/7] Keep CI trust-free for self-signed prototype --- .github/macos-release-signing.md | 29 +++++++---------------- .github/workflows/build.yml | 6 ++--- .github/workflows/release.yml | 20 +--------------- scripts/test_macos_stable_identity_e2e.sh | 24 +------------------ 4 files changed, 13 insertions(+), 66 deletions(-) diff --git a/.github/macos-release-signing.md b/.github/macos-release-signing.md index 00e7bafd..bedab8dc 100644 --- a/.github/macos-release-signing.md +++ b/.github/macos-release-signing.md @@ -17,26 +17,15 @@ Do not replace the certificate anchor with a bundle-ID-only requirement. Any bin ## Create release identity -Create this identity once on a secure Mac. The common name must remain `Entropy Open Source Release Signing` because the release workflow uses that exact name. +Create this identity once on a secure Mac using Keychain Access > Certificate Assistant > Create a Certificate: -```bash -umask 077 -openssl req -new -newkey rsa:4096 -x509 -sha256 -nodes \ - -days 3650 \ - -subj '/CN=Entropy Open Source Release Signing/O=Entropy Open Source' \ - -addext 'basicConstraints=critical,CA:TRUE' \ - -addext 'keyUsage=critical,digitalSignature,keyCertSign' \ - -addext 'extendedKeyUsage=codeSigning' \ - -keyout entropy-release-signing.key \ - -out entropy-release-signing.pem -openssl pkcs12 -export \ - -inkey entropy-release-signing.key \ - -in entropy-release-signing.pem \ - -name 'Entropy Open Source Release Signing' \ - -out entropy-release-signing.p12 -``` +1. Name: `Entropy Open Source Release Signing`. +2. Identity Type: Self Signed Root. +3. Certificate Type: Code Signing. +4. Enable Let me override defaults and choose a long validity period. +5. Export certificate and private key together as an encrypted `.p12`. -Store encrypted `.p12` and its password in maintainer-controlled offline backup. Delete unencrypted `.key` after backup and GitHub configuration. Never commit private material. +The common name must remain exact because release workflow uses it to select identity. Store encrypted `.p12` and password in maintainer-controlled offline backup. Never commit private material. Configure two GitHub Actions repository secrets: @@ -45,7 +34,7 @@ Configure two GitHub Actions repository secrets: | `MACOS_CERTIFICATE_P12_BASE64` | `base64 -i entropy-release-signing.p12` output | | `MACOS_CERTIFICATE_PASSWORD` | `.p12` export password | -Release workflow imports identity into a temporary keychain, trusts it for code signing on the ephemeral runner, derives certificate hash, embeds an explicit requirement containing that certificate plus `com.ergohaven.entropy`, signs app, removes runner trust, and rejects ad-hoc or mismatched output. DMG is not notarized. Shipped requirement does not contain `anchor trusted`; users do not install or trust this certificate. +Release workflow imports identity into a temporary keychain, derives certificate hash, embeds an explicit requirement containing that certificate plus `com.ergohaven.entropy`, signs app, and rejects ad-hoc or mismatched output. DMG is not notarized. Shipped requirement does not contain `anchor trusted`; users do not install or trust this certificate. ## Automated proof @@ -56,7 +45,7 @@ scripts/test_macos_stable_signing.sh scripts/test_macos_stable_identity_e2e.sh ``` -End-to-end test creates temporary self-signed identity and two different binaries. Test passes only when code hashes differ while designated requirements match. Temporary keychain and keys are deleted on exit. +End-to-end test creates temporary self-signed identity and two different binaries. Test passes only when code hashes differ while designated requirements match. Temporary keychain and keys are deleted on exit. Run it on macOS 26 before manual TCC testing; macOS 15 runners do not expose generic OpenSSL-generated self-signed certificates as code-signing identities. ## Required manual TCC test diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 34823d53..95d77fbd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -92,11 +92,9 @@ jobs: cargo test --all-targets app::vial_hid_task::tests::macro_write_uses_the_serialized_background_transport -- --exact cargo test --all-targets app::app_lifecycle::tests::layer_completion_starts_settings_queued_behind_it -- --exact - - name: Test macOS stable signing identity + - name: Test macOS stable signing validation if: matrix.kind == 'macos' - run: | - scripts/test_macos_stable_signing.sh - scripts/test_macos_stable_identity_e2e.sh + run: scripts/test_macos_stable_signing.sh - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d488036f..0e279771 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -62,7 +62,6 @@ jobs: certificate_path="$RUNNER_TEMP/entropy-release-signing.p12" certificate_pem_path="$RUNNER_TEMP/entropy-release-signing.pem" - certificate_der_path="$RUNNER_TEMP/entropy-release-signing.cer" keychain_path="$RUNNER_TEMP/entropy-signing.keychain-db" keychain_password="$(openssl rand -base64 32)" @@ -88,10 +87,6 @@ jobs: -nokeys \ -passin "pass:$MACOS_CERTIFICATE_PASSWORD" \ -out "$certificate_pem_path" - openssl x509 \ - -in "$certificate_pem_path" \ - -outform DER \ - -out "$certificate_der_path" certificate_sha1="$( openssl x509 \ -in "$certificate_pem_path" \ @@ -104,12 +99,6 @@ jobs: echo "Could not derive macOS signing certificate SHA-1" >&2 exit 1 fi - sudo security add-trusted-cert \ - -d \ - -r trustRoot \ - -p codeSign \ - -k /Library/Keychains/System.keychain \ - "$certificate_der_path" echo "MACOS_SIGNING_CERTIFICATE_SHA1=$certificate_sha1" >> "$GITHUB_ENV" - name: Install Linux dependencies @@ -174,17 +163,10 @@ jobs: shell: bash run: | keychain_path="$RUNNER_TEMP/entropy-signing.keychain-db" - certificate_der_path="$RUNNER_TEMP/entropy-release-signing.cer" - if [[ -f "$certificate_der_path" ]]; then - sudo security remove-trusted-cert \ - -d \ - "$certificate_der_path" || true - fi security delete-keychain "$keychain_path" || true rm -f \ "$RUNNER_TEMP/entropy-release-signing.p12" \ - "$RUNNER_TEMP/entropy-release-signing.pem" \ - "$RUNNER_TEMP/entropy-release-signing.cer" + "$RUNNER_TEMP/entropy-release-signing.pem" - name: Upload artifact uses: actions/upload-artifact@v4 diff --git a/scripts/test_macos_stable_identity_e2e.sh b/scripts/test_macos_stable_identity_e2e.sh index 9a69fd85..348ca8b2 100755 --- a/scripts/test_macos_stable_identity_e2e.sh +++ b/scripts/test_macos_stable_identity_e2e.sh @@ -16,7 +16,6 @@ P12_PASSWORD="entropy-test-p12" IDENTITY="Entropy Open Source Release Signing" BUNDLE_ID="com.ergohaven.entropy" ORIGINAL_KEYCHAINS=() -SYSTEM_TRUST_ADDED=0 while IFS= read -r keychain; do keychain="${keychain//\"/}" @@ -24,11 +23,6 @@ while IFS= read -r keychain; do done < <(security list-keychains -d user) cleanup() { - if [[ "$SYSTEM_TRUST_ADDED" == "1" ]]; then - sudo -n security remove-trusted-cert \ - -d \ - "$TMP_DIR/imported-cert.cer" >/dev/null 2>&1 || true - fi security list-keychains -d user -s "${ORIGINAL_KEYCHAINS[@]}" >/dev/null 2>&1 || true security delete-keychain "$KEYCHAIN_PATH" >/dev/null 2>&1 || true rm -rf "$TMP_DIR" @@ -70,10 +64,6 @@ openssl pkcs12 \ -nokeys \ -passin "pass:$P12_PASSWORD" \ -out "$TMP_DIR/imported-cert.pem" -openssl x509 \ - -in "$TMP_DIR/imported-cert.pem" \ - -outform DER \ - -out "$TMP_DIR/imported-cert.cer" printf '%s\n' 'int main(void) { return 1; }' | clang -x c - -o "$TMP_DIR/entropy-v1" @@ -99,19 +89,7 @@ sign_test_binary() { "$binary" } -if ! sign_test_binary "$TMP_DIR/entropy-v1"; then - if ! sudo -n security add-trusted-cert \ - -d \ - -r trustRoot \ - -p codeSign \ - -k /Library/Keychains/System.keychain \ - "$TMP_DIR/imported-cert.cer"; then - echo "Self-signed identity failed signing and runner trust could not be added" >&2 - exit 1 - fi - SYSTEM_TRUST_ADDED=1 - sign_test_binary "$TMP_DIR/entropy-v1" -fi +sign_test_binary "$TMP_DIR/entropy-v1" macos_verify_stable_signature \ "$TMP_DIR/entropy-v1" \ "$CERTIFICATE_SHA1" \ From 9f90f134435f214084884ff3fc00efc1b2b11ea8 Mon Sep 17 00:00:00 2001 From: Igor Arkhipov Date: Mon, 31 Aug 2026 12:39:40 -0700 Subject: [PATCH 7/7] fix: harden macOS release signing workflow Compile release binaries before unlocking the project signing key, restrict the imported identity to codesign, and reject certificate rotation unless its approved fingerprint is updated. Exercise the split compile/package path in pull-request CI and cover the designated-requirement generator directly. --- .github/macos-release-signing.md | 18 +++++- .github/workflows/build.yml | 8 +++ .github/workflows/release.yml | 67 +++++++++++++++-------- scripts/build_macos_app.sh | 17 +++++- scripts/test_macos_stable_identity_e2e.sh | 2 +- scripts/test_macos_stable_signing.sh | 6 ++ 6 files changed, 92 insertions(+), 26 deletions(-) diff --git a/.github/macos-release-signing.md b/.github/macos-release-signing.md index bedab8dc..fdd465c0 100644 --- a/.github/macos-release-signing.md +++ b/.github/macos-release-signing.md @@ -15,6 +15,8 @@ This prototype uses one project-owned self-signed code-signing certificate acros Do not replace the certificate anchor with a bundle-ID-only requirement. Any binary can copy a bundle ID; Accessibility grants make that unsafe. +Before storing the signing secrets, create a repository ruleset for `v0.*` tags that restricts tag creation, updates, and deletion to trusted release maintainers and blocks force updates. The release workflow executes code from the tagged commit before using the project signing key, so branch protection alone is not sufficient. + ## Create release identity Create this identity once on a secure Mac using Keychain Access > Certificate Assistant > Create a Certificate: @@ -27,14 +29,26 @@ Create this identity once on a secure Mac using Keychain Access > Certificate As The common name must remain exact because release workflow uses it to select identity. Store encrypted `.p12` and password in maintainer-controlled offline backup. Never commit private material. -Configure two GitHub Actions repository secrets: +Configure two GitHub Actions repository secrets and one repository variable: | Secret | Value | | --- | --- | | `MACOS_CERTIFICATE_P12_BASE64` | `base64 -i entropy-release-signing.p12` output | | `MACOS_CERTIFICATE_PASSWORD` | `.p12` export password | -Release workflow imports identity into a temporary keychain, derives certificate hash, embeds an explicit requirement containing that certificate plus `com.ergohaven.entropy`, signs app, and rejects ad-hoc or mismatched output. DMG is not notarized. Shipped requirement does not contain `anchor trusted`; users do not install or trust this certificate. +| Variable | Value | +| --- | --- | +| `MACOS_SIGNING_CERTIFICATE_SHA1` | Approved 40-character SHA-1 fingerprint of the signing certificate, without colons | + +Derive the public fingerprint from the exported identity, verify it against the certificate shown in Keychain Access, and store the result as the repository variable: + +```bash +openssl pkcs12 -in entropy-release-signing.p12 -nokeys | + openssl x509 -noout -fingerprint -sha1 | + sed 's/^.*=//; s/://g' +``` + +Release workflow builds before loading signing credentials, imports identity into a temporary keychain restricted to `/usr/bin/codesign`, verifies its certificate hash against the approved repository variable, embeds an explicit requirement containing that certificate plus `com.ergohaven.entropy`, signs app, and rejects ad-hoc or mismatched output. DMG is not notarized. Shipped requirement does not contain `anchor trusted`; users do not install or trust this certificate. ## Automated proof diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 95d77fbd..56965ff7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -72,8 +72,16 @@ jobs: if: matrix.kind != 'macos' run: cargo build --release + - name: Build macOS binary + if: matrix.kind == 'macos' + env: + MACOSX_DEPLOYMENT_TARGET: '10.15' + run: cargo build --release --target "${{ matrix.target }}" + - name: Build macOS app bundle if: matrix.kind == 'macos' + env: + SKIP_CARGO_BUILD: '1' run: TARGET="${{ matrix.target }}" scripts/build_macos_app.sh - name: Test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e279771..116380b8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,13 +45,38 @@ jobs: if: matrix.kind == 'macos' run: rustup target add ${{ matrix.target }} + - name: Install Linux dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get update + sudo apt-get install -y \ + libhidapi-dev \ + libudev-dev \ + libxcb-render0-dev \ + libxcb-shape0-dev \ + libxcb-xfixes0-dev \ + libxkbcommon-dev \ + libssl-dev \ + libgtk-3-dev + + - name: Build + if: matrix.kind == 'windows' + run: cargo build --release + + - name: Build macOS binary + if: matrix.kind == 'macos' + shell: bash + env: + MACOSX_DEPLOYMENT_TARGET: '10.15' + run: cargo build --release --target "${{ matrix.target }}" + - name: Import macOS self-signed release identity if: matrix.kind == 'macos' shell: bash env: CERTIFICATE_P12_BASE64: ${{ secrets.MACOS_CERTIFICATE_P12_BASE64 }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} - MACOS_SIGNING_IDENTITY: Entropy Open Source Release Signing + EXPECTED_MACOS_SIGNING_CERTIFICATE_SHA1: ${{ vars.MACOS_SIGNING_CERTIFICATE_SHA1 }} run: | umask 077 if [[ -z "$CERTIFICATE_P12_BASE64" ]] || @@ -60,6 +85,16 @@ jobs: exit 1 fi + expected_certificate_sha1="$( + printf '%s' "$EXPECTED_MACOS_SIGNING_CERTIFICATE_SHA1" | + tr -d '[:space:]:' | + tr '[:lower:]' '[:upper:]' + )" + if [[ ! "$expected_certificate_sha1" =~ ^[[:xdigit:]]{40}$ ]]; then + echo "MACOS_SIGNING_CERTIFICATE_SHA1 must be a 40-character SHA-1 fingerprint" >&2 + exit 1 + fi + certificate_path="$RUNNER_TEMP/entropy-release-signing.p12" certificate_pem_path="$RUNNER_TEMP/entropy-release-signing.pem" keychain_path="$RUNNER_TEMP/entropy-signing.keychain-db" @@ -72,7 +107,7 @@ jobs: security unlock-keychain -p "$keychain_password" "$keychain_path" security import "$certificate_path" \ -P "$MACOS_CERTIFICATE_PASSWORD" \ - -A \ + -T /usr/bin/codesign \ -f pkcs12 \ -k "$keychain_path" security set-key-partition-list \ @@ -93,32 +128,19 @@ jobs: -noout \ -fingerprint \ -sha1 | - sed 's/^.*=//; s/://g' + sed 's/^.*=//; s/://g' | + tr '[:lower:]' '[:upper:]' )" if [[ ! "$certificate_sha1" =~ ^[[:xdigit:]]{40}$ ]]; then echo "Could not derive macOS signing certificate SHA-1" >&2 exit 1 fi + if [[ "$certificate_sha1" != "$expected_certificate_sha1" ]]; then + echo "Configured macOS signing certificate does not match the approved fingerprint" >&2 + exit 1 + fi echo "MACOS_SIGNING_CERTIFICATE_SHA1=$certificate_sha1" >> "$GITHUB_ENV" - - name: Install Linux dependencies - if: matrix.os == 'ubuntu-latest' - run: | - sudo apt-get update - sudo apt-get install -y \ - libhidapi-dev \ - libudev-dev \ - libxcb-render0-dev \ - libxcb-shape0-dev \ - libxcb-xfixes0-dev \ - libxkbcommon-dev \ - libssl-dev \ - libgtk-3-dev - - - name: Build - if: matrix.kind == 'windows' - run: cargo build --release - - name: Package Linux artifact if: matrix.kind == 'linux' shell: bash @@ -141,6 +163,7 @@ jobs: CODESIGN_IDENTITY: Entropy Open Source Release Signing CODESIGN_KEYCHAIN: ${{ runner.temp }}/entropy-signing.keychain-db REQUIRE_STABLE_SIGNING: '1' + SKIP_CARGO_BUILD: '1' run: | TARGET="${{ matrix.target }}" scripts/build_macos_app.sh @@ -203,7 +226,7 @@ jobs: printf '%s\n' '- Linux: download the `.AppImage`, make it executable, and run it.' >> RELEASE_NOTES.md printf '%s\n' '- Windows: download and run the portable `.exe`.' >> RELEASE_NOTES.md printf '%s\n' '- macOS: download the project-signed, non-notarized `.dmg` for your Mac (`arm64` for Apple Silicon, `x86_64` for Intel), then drag Entropy to Applications. macOS may require Privacy & Security > Open Anyway.' >> RELEASE_NOTES.md - printf '%s\n' '- When upgrading from Entropy v0.2.0 or earlier, remove old Entropy entries from Accessibility and Input Monitoring, then grant both permissions once for the project-signed app. Later builds signed with the same project key are intended to preserve this identity.' >> RELEASE_NOTES.md + printf '%s\n' '- When upgrading from an earlier unsigned Entropy release, remove old Entropy entries from Accessibility and Input Monitoring, then grant both permissions once for the project-signed app. Later builds signed with the same project key are intended to preserve this identity.' >> RELEASE_NOTES.md - name: Create GitHub Release uses: softprops/action-gh-release@v2 diff --git a/scripts/build_macos_app.sh b/scripts/build_macos_app.sh index 064d36e8..7b9a8b88 100755 --- a/scripts/build_macos_app.sh +++ b/scripts/build_macos_app.sh @@ -8,6 +8,7 @@ CODESIGN_IDENTITY="${CODESIGN_IDENTITY:--}" CODESIGN_KEYCHAIN="${CODESIGN_KEYCHAIN:-}" MACOS_SIGNING_CERTIFICATE_SHA1="${MACOS_SIGNING_CERTIFICATE_SHA1:-}" REQUIRE_STABLE_SIGNING="${REQUIRE_STABLE_SIGNING:-0}" +SKIP_CARGO_BUILD="${SKIP_CARGO_BUILD:-0}" MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-10.15}" export MACOSX_DEPLOYMENT_TARGET @@ -160,7 +161,21 @@ macos_validate_stable_signing_configuration \ "$BUNDLE_ID" cd "$ROOT" -cargo build "${BUILD_ARGS[@]}" +case "$SKIP_CARGO_BUILD" in +0) + cargo build "${BUILD_ARGS[@]}" + ;; +1) + if [[ ! -x "$BIN" ]]; then + echo "SKIP_CARGO_BUILD=1 requires a prebuilt executable at $BIN" >&2 + exit 1 + fi + ;; +*) + echo "SKIP_CARGO_BUILD must be 0 or 1" >&2 + exit 1 + ;; +esac validate_binary_arch rm -rf "$APP_PATH" "$ZIP_PATH" "$DMG_PATH" diff --git a/scripts/test_macos_stable_identity_e2e.sh b/scripts/test_macos_stable_identity_e2e.sh index 348ca8b2..8bf6065f 100755 --- a/scripts/test_macos_stable_identity_e2e.sh +++ b/scripts/test_macos_stable_identity_e2e.sh @@ -49,7 +49,7 @@ security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" security import "$TMP_DIR/identity.p12" \ -P "$P12_PASSWORD" \ - -A \ + -T /usr/bin/codesign \ -f pkcs12 \ -k "$KEYCHAIN_PATH" >/dev/null security set-key-partition-list \ diff --git a/scripts/test_macos_stable_signing.sh b/scripts/test_macos_stable_signing.sh index 438bafde..a7d1f863 100755 --- a/scripts/test_macos_stable_signing.sh +++ b/scripts/test_macos_stable_signing.sh @@ -29,6 +29,12 @@ certificate_sha1='0123456789abcdef0123456789abcdef01234567' bundle_id='com.ergohaven.entropy' self_signed_details=$'Authority=Entropy Open Source Release Signing\nTeamIdentifier=not set' self_signed_requirement="designated => certificate root = H\"$certificate_sha1\" and identifier \"$bundle_id\"" +generated_requirement="$(macos_stable_designated_requirement "$certificate_sha1" "$bundle_id")" +expected_generated_requirement="=designated => anchor = H\"$certificate_sha1\" and identifier \"$bundle_id\"" + +if [[ "$generated_requirement" != "$expected_generated_requirement" ]]; then + fail "stable designated requirement generator returned an unexpected requirement" +fi expect_failure \ "required stable signing accepted an ad-hoc identity" \