Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions .github/macos-release-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# macOS stable permission identity prototype

Entropy currently ships GitHub artifact files without Apple Developer Program membership. Developer ID signing and notarization are therefore unavailable.

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 using Keychain Access > Certificate Assistant > Create a Certificate:

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`.

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:

| 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.

## Automated proof

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. 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

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"<same-certificate-sha1>" and identifier "com.ergohaven.entropy"
```

`spctl` rejection is expected for this prototype. Any `cdhash`-only requirement, changed certificate hash, or repeated TCC grant invalidates prototype.
4 changes: 4 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ jobs:
if: matrix.kind == 'linux'
run: cargo test --all-targets

- name: Test macOS stable signing validation
if: matrix.kind == 'macos'
run: scripts/test_macos_stable_signing.sh

- name: Upload artifact
uses: actions/upload-artifact@v4
with:
Expand Down
86 changes: 84 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,62 @@ jobs:
if: matrix.kind == 'macos'
run: rustup target add ${{ 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
run: |
umask 077
if [[ -z "$CERTIFICATE_P12_BASE64" ]] ||
[[ -z "$MACOS_CERTIFICATE_PASSWORD" ]]; then
echo "macOS stable signing secrets are not configured" >&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"
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 \
-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"

openssl pkcs12 \
-in "$certificate_path" \
-nokeys \
-passin "pass:$MACOS_CERTIFICATE_PASSWORD" \
-out "$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: |
Expand Down Expand Up @@ -81,11 +137,36 @@ jobs:
- name: Build macOS app bundle and DMG
if: matrix.kind == 'macos'
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

- name: Package macOS release artifact
if: matrix.kind == 'macos'
shell: bash
run: |
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

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-release-signing.p12" \
"$RUNNER_TEMP/entropy-release-signing.pem"

- name: Upload artifact
uses: actions/upload-artifact@v4
Expand Down Expand Up @@ -121,7 +202,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 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
Expand Down
39 changes: 38 additions & 1 deletion scripts/build_macos_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,14 @@ 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}"
export MACOSX_DEPLOYMENT_TARGET

source "$ROOT/scripts/macos_stable_signing.sh"

VERSION="$(
awk -F '"' '/^version = / { print $2; exit }' "$ROOT/Cargo.toml"
)"
Expand Down Expand Up @@ -81,17 +86,40 @@ sign_app_bundle() {
fi

if ! command -v codesign >/dev/null 2>&1; then
if [[ "$REQUIRE_STABLE_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

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
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"
codesign --verify --strict "$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
}

create_dmg_with_retries() {
Expand Down Expand Up @@ -125,6 +153,12 @@ create_dmg_with_retries() {
return "$status"
}

macos_validate_stable_signing_configuration \
"$CODESIGN_IDENTITY" \
"$REQUIRE_STABLE_SIGNING" \
"$MACOS_SIGNING_CERTIFICATE_SHA1" \
"$BUNDLE_ID"

cd "$ROOT"
cargo build "${BUILD_ARGS[@]}"
validate_binary_arch
Expand Down Expand Up @@ -188,6 +222,9 @@ fi

if command -v hdiutil >/dev/null 2>&1; then
create_dmg_with_retries
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"
fi
Expand Down
87 changes: 87 additions & 0 deletions scripts/macos_stable_signing.sh
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading