From 061ae098daa9ae40ff68c9cc9e5cdaa253439b6d Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Wed, 5 Aug 2026 13:23:09 +0600 Subject: [PATCH] Sign builds and releases with a stable code identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit swiftc linker-signs only the inner binary, leaving the bundle unsigned and its codesign identifier as "launcher" rather than the bundle id. More importantly it leaves the app ad-hoc signed, and macOS pins an ad-hoc app's Accessibility grant to its exact cdhash instead of to a designated requirement. Every build mints a new cdhash, so each rebuild-and-replace silently revoked the permission while the app stayed listed and ticked under Privacy & Security — and every release did the same to everyone who updated. That was the root cause of the window tags never appearing. - build.sh signs the bundle with its real identifier, honours SHANNONCOAT_SIGN_IDENTITY, and fails outright rather than falling back to ad-hoc when an identity was asked for explicitly. - The identity is read from a gitignored .env, so it doesn't have to be retyped every build. Parsed rather than sourced, so a stray command in the file can't execute as a side effect of building, and so an existing environment variable still wins. .env.example carries the full one-time setup. - release.yml imports the certificate into a throwaway keychain, builds, verifies, and deletes the keychain on if: always(). It stays inert until the three secrets exist, and fails the release rather than shipping ad-hoc. - Guards the empty-password case explicitly: macOS cannot import an OpenSSL-produced PKCS#12 with an empty password, and reports it as "MAC verification failed (wrong password?)", which sends you hunting for a wrong password rather than a missing one. Nothing local catches this, since the login keychain imports the PEM pair and needs no password. - Ignores *.p12 and *.pem as a backstop; the certificate belongs outside the working tree entirely. Verified end-to-end: two from-scratch builds produce byte-identical designated requirements where ad-hoc differs every time, and six rebuild-reinstall cycles under a real certificate kept the Accessibility grant with no System Settings interaction. This buys permission persistence, not Gatekeeper approval — a self-signed certificate isn't notarized, so downloads still need System Settings -> Privacy & Security -> Open Anyway. --- .env.example | 118 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 108 +++++++++++++++++++++++++++++++ .gitignore | 12 ++++ VERSION | 2 +- build.sh | 58 +++++++++++++++++ 5 files changed, 297 insertions(+), 1 deletion(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b97ed75 --- /dev/null +++ b/.env.example @@ -0,0 +1,118 @@ +# Local build configuration. Copy to `.env` (gitignored) and fill in. +# +# cp .env.example .env +# +# build.sh reads `.env` automatically if it exists. Anything already set in +# the environment wins over this file, so a one-off +# +# SHANNONCOAT_SIGN_IDENTITY= ./build.sh +# +# still forces an ad-hoc build without editing anything. + + +# --------------------------------------------------------------------------- +# Code-signing identity +# --------------------------------------------------------------------------- +# The common name of the code-signing certificate in your keychain. Not a +# secret — it's a label, not a credential — but it lives here so every build +# picks it up without you having to remember the variable. +# +# Why bother signing at all: macOS pins an *ad-hoc* signed app's +# Accessibility grant to its exact cdhash, which changes on every build. So +# an unsigned local build silently loses the permission each time you +# rebuild and reinstall, while still appearing listed and ticked under +# Privacy & Security → Accessibility. Signing with a certificate gives the +# app a stable designated requirement, and the grant survives. +# +# Leave empty (or delete the line) to build ad-hoc. +SHANNONCOAT_SIGN_IDENTITY="shannoncoat Signing" + + +# --------------------------------------------------------------------------- +# Certificate material — only needed to publish the GitHub Actions secrets +# --------------------------------------------------------------------------- +# The build itself never reads these: once the certificate is imported, the +# private key lives in your keychain and codesign finds it by the identity +# name above. They're recorded here purely so the `gh secret set` commands +# at the bottom have somewhere to read from. +# +# Keep the .p12 OUTSIDE the working tree. A gitignored file is still one +# `git add -f`, one editor-indexed backup, or one shared folder away from +# leaking, and this one holds a private key. +# +# The $HOME below is expanded by the shell when you `source .env` (which is +# how the gh commands at the bottom read it). build.sh parses rather than +# sources this file, so it takes values literally — which costs nothing +# here, as the only variable it actually reads is the identity above. +SHANNONCOAT_P12_PATH="$HOME/.shannoncoat-signing/shannoncoat-signing.p12" + +# The .p12 export password. Storing it in plaintext here is weaker than +# leaving it in your password manager and typing it when prompted — prefer +# leaving this field empty and letting `gh secret set` ask for it +# interactively. +# +# Leaving the *field* empty is fine. Exporting the .p12 itself with an +# empty password is not: macOS cannot import one, so CI would fail even +# though local builds carry on working (they import the PEM pair, which +# needs no password). Give the export a real password. +SHANNONCOAT_P12_PASSWORD="" + + +# --------------------------------------------------------------------------- +# One-time setup +# --------------------------------------------------------------------------- +# Generate a ten-year self-signed code-signing certificate (codesign refuses +# an expired one, and replacing it later resets every user's Accessibility +# permission again): +# +# mkdir -p ~/.shannoncoat-signing && chmod 700 ~/.shannoncoat-signing +# openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \ +# -keyout ~/.shannoncoat-signing/key.pem \ +# -out ~/.shannoncoat-signing/cert.pem \ +# -subj "/CN=shannoncoat Signing" \ +# -addext "basicConstraints=critical,CA:false" \ +# -addext "keyUsage=critical,digitalSignature" \ +# -addext "extendedKeyUsage=critical,codeSigning" +# +# Bundle it as a .p12. `-legacy` is required: OpenSSL 3's default encoding +# is one macOS cannot read, and `security import` then fails with a +# misleading "MAC verification failed (wrong password?)". +# +# openssl pkcs12 -export -legacy \ +# -inkey ~/.shannoncoat-signing/key.pem \ +# -in ~/.shannoncoat-signing/cert.pem \ +# -name "shannoncoat Signing" \ +# -out ~/.shannoncoat-signing/shannoncoat-signing.p12 +# +# Import for local builds, then delete the now-redundant loose private key +# (the .p12 remains your only backup, so keep that): +# +# security import ~/.shannoncoat-signing/shannoncoat-signing.p12 \ +# -k ~/Library/Keychains/login.keychain-db -T /usr/bin/codesign +# rm ~/.shannoncoat-signing/key.pem +# +# The first signed build raises a "codesign wants to use key…" dialog — +# choose Always Allow. Then re-grant Accessibility one final time; from +# then on it persists across rebuilds. +# +# Note that `security find-identity -v -p codesigning` will report "0 valid +# identities": -v filters to *trusted* certificates and a self-signed one +# reads as CSSMERR_TP_NOT_TRUSTED. codesign uses it regardless. Drop the -v +# to see it. + + +# --------------------------------------------------------------------------- +# Publishing the same certificate to GitHub Actions +# --------------------------------------------------------------------------- +# Releases need this too — an ad-hoc release revokes the Accessibility +# permission of everyone who updates. .github/workflows/release.yml stays +# inert until all three secrets exist, so nothing breaks in the meantime. +# +# source .env +# base64 -i "$SHANNONCOAT_P12_PATH" | gh secret set SIGNING_CERTIFICATE_P12 +# gh secret set SIGNING_IDENTITY --body "$SHANNONCOAT_SIGN_IDENTITY" +# gh secret set SIGNING_CERTIFICATE_PASSWORD # prompts, so it stays out of shell history +# +# This buys permission persistence, not Gatekeeper approval: a self-signed +# certificate isn't notarized, so downloads are still blocked on first open +# and need System Settings → Privacy & Security → Open Anyway. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 00bae71..b726ca8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,11 +21,94 @@ permissions: jobs: build: runs-on: macos-latest + env: + # Not a secret (it's just the certificate's common name), and needed + # in a step `if:` — where the `secrets` context isn't available, but + # `env` is. Empty when signing isn't configured, which every step + # below treats as "fall back to ad-hoc". + SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }} steps: - uses: actions/checkout@v4 with: ref: ${{ inputs.tag || github.ref }} + # Releases need a *stable* code identity, not an ad-hoc one. macOS + # pins an ad-hoc app's Accessibility grant to its exact cdhash, which + # changes on every build — so each release would silently revoke the + # permission users had already granted, leaving a stale entry sitting + # in System Settings still ticked (and un-fixable by toggling it). + # A certificate gives the app a designated requirement that survives + # updates instead. + # + # This does NOT make the app Gatekeeper-clean: a self-signed cert + # isn't notarized, so downloads still need right-click → Open. Only + # a paid Developer ID plus notarization changes that. + # + # One-time setup, ideally with the same self-signed certificate used + # for local builds (create it with a long validity — codesign refuses + # to use an expired cert, and re-signing under a new one resets every + # user's permission again): + # security export -k login.keychain -t identities -f pkcs12 \ + # -P '' -o cert.p12 + # base64 -i cert.p12 + # then add three repository secrets — SIGNING_CERTIFICATE_P12 (that + # base64 blob), SIGNING_CERTIFICATE_PASSWORD, and SIGNING_IDENTITY + # (the certificate's common name). + # + # If you build the .p12 with OpenSSL 3 rather than exporting it, pass + # `-legacy`: its default AES-256/SHA-256 PKCS#12 encoding is one the + # macOS Security framework can't read, and `security import` fails + # with a misleading "MAC verification failed (wrong password?)". + - name: Import signing certificate + if: env.SIGNING_IDENTITY != '' + env: + CERT_P12: ${{ secrets.SIGNING_CERTIFICATE_P12 }} + CERT_PASSWORD: ${{ secrets.SIGNING_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + + # macOS cannot import an OpenSSL-produced PKCS#12 that has an + # empty password — the two disagree over the spec's empty-string + # vs. NULL password ambiguity, and `security import` reports it + # as "MAC verification failed (wrong password?)", which sends you + # hunting for a wrong password rather than a missing one. Say + # what's actually wrong instead. + # + # Easy to hit, because a local build never exercises this: the + # local keychain imports the PEM pair directly and needs no + # password at all. + if [[ -z "${CERT_PASSWORD:-}" ]]; then + echo "error: SIGNING_CERTIFICATE_PASSWORD is empty." >&2 + echo "The .p12 must be exported with a non-empty password; regenerate it with" >&2 + echo " openssl pkcs12 -export -legacy -inkey key.pem -in cert.pem -out cert.p12" >&2 + echo "and update both SIGNING_CERTIFICATE_P12 and SIGNING_CERTIFICATE_PASSWORD." >&2 + exit 1 + fi + + KEYCHAIN="$RUNNER_TEMP/signing.keychain-db" + KEYCHAIN_PASSWORD="$(uuidgen)" + CERT_PATH="$RUNNER_TEMP/cert.p12" + + printf '%s' "$CERT_P12" | base64 --decode > "$CERT_PATH" + + # A dedicated throwaway keychain, not the login one: it starts + # unlocked, needs no interactive prompt, and is deleted at the + # end of the job regardless of outcome. + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + security set-keychain-settings -lut 21600 "$KEYCHAIN" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN" + + security import "$CERT_PATH" -k "$KEYCHAIN" -P "$CERT_PASSWORD" \ + -T /usr/bin/codesign + # Without this, codesign hits an interactive "allow access to + # key?" prompt that nothing can answer on a headless runner. + security set-key-partition-list -S apple-tool:,apple: \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN" >/dev/null + # codesign only searches keychains on the search list. + security list-keychain -d user -s "$KEYCHAIN" login.keychain-db + + rm -f "$CERT_PATH" + - name: Build shannoncoat.app run: ./build.sh env: @@ -34,6 +117,23 @@ jobs: # it re-derive that via `git describe`, which needs full tag # refs a CI runner's checkout isn't guaranteed to have fetched. SHANNONCOAT_RELEASE_BUILD: "1" + # Empty when unconfigured, which build.sh reads as ad-hoc. + SHANNONCOAT_SIGN_IDENTITY: ${{ env.SIGNING_IDENTITY }} + + # Catches a release that silently went out ad-hoc — the failure this + # whole arrangement exists to prevent, and one that's invisible in + # the artifact until someone's permission stops working. + - name: Verify signature + run: | + set -euo pipefail + codesign --verify --strict --verbose=2 ".build/shannoncoat.app" + codesign -dvvv ".build/shannoncoat.app" 2>&1 \ + | grep -E 'Identifier=|Authority=|Signature=' || true + if [[ -n "$SIGNING_IDENTITY" ]] \ + && codesign -dvvv ".build/shannoncoat.app" 2>&1 | grep -q 'Signature=adhoc'; then + echo "error: signing was configured but the app is still ad-hoc signed" >&2 + exit 1 + fi - name: Zip app bundle run: ditto -c -k --sequesterRsrc --keepParent ".build/shannoncoat.app" "shannoncoat.app.zip" @@ -45,3 +145,11 @@ jobs: files: | shannoncoat.app.zip generate_release_notes: true + + # `if: always()` so a failed build can't leave the signing key + # sitting in a keychain on the runner. + - name: Clean up keychain + if: always() && env.SIGNING_IDENTITY != '' + run: | + security list-keychain -d user -s login.keychain-db || true + security delete-keychain "$RUNNER_TEMP/signing.keychain-db" || true diff --git a/.gitignore b/.gitignore index 1c97b0e..1345d6b 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,18 @@ icon/AppIcon.iconset/ # Session handoff notes — local only, never committed CHECKPOINT.md +# Local build configuration, read by build.sh — holds the code-signing +# identity and, optionally, the certificate password. See .env.example for +# the shape of it. Never committed. +.env + +# Belt and braces: private key material must never land in the repo, even +# gitignored. Keep the certificate itself outside the working tree (the +# setup notes in .env.example use ~/.shannoncoat-signing/) — this pattern +# only exists to catch a stray copy before it becomes a commit. +*.p12 +*.pem + # Local reference material (e.g. nested checkouts of other projects) — not # part of this project, never meant to be committed. .scratch/ diff --git a/VERSION b/VERSION index 5a5831a..d169b2f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.7 +0.0.8 diff --git a/build.sh b/build.sh index 95a1306..b0cd96f 100755 --- a/build.sh +++ b/build.sh @@ -13,6 +13,29 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SOURCES_DIR="$SCRIPT_DIR/Sources" + +# Local, gitignored build configuration — currently just the code-signing +# identity, which otherwise has to be remembered and retyped on every +# build. See .env.example. +# +# Parsed rather than sourced, for two reasons: a stray command in the file +# shouldn't execute as a side effect of building, and this lets an existing +# environment variable win, so `SHANNONCOAT_SIGN_IDENTITY= ./build.sh` +# still forces a one-off ad-hoc build without editing the file. +ENV_FILE="$SCRIPT_DIR/.env" +if [[ -f "$ENV_FILE" ]]; then + while IFS='=' read -r key value || [[ -n "$key" ]]; do + key="${key#"${key%%[![:space:]]*}"}" # strip leading whitespace + key="${key#export }" + [[ "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue # skips blanks and # comments + value="${value%"${value##*[![:space:]]}"}" # strip trailing whitespace + value="${value%\"}"; value="${value#\"}" + value="${value%\'}"; value="${value#\'}" + [[ -n "${!key+set}" ]] && continue # already in the environment; leave it + printf -v "$key" '%s' "$value" + export "${key?}" + done <"$ENV_FILE" +fi VERSION_FILE="$SCRIPT_DIR/VERSION" ICON_SRC="$SCRIPT_DIR/icon/AppIcon.icns" OUT_DIR="${1:-$SCRIPT_DIR/.build}" @@ -76,6 +99,41 @@ printf '%s' "$GIT_SHA" >"$APP/Contents/Resources/COMMIT" swiftc -O "$SOURCES_DIR"/*.swift -o "$APP/Contents/MacOS/launcher" +# Sign the bundle, not just the binary swiftc linker-signed for us — and +# with the bundle's real identifier rather than the "launcher" executable +# name codesign would otherwise infer. +# +# This matters well beyond tidiness, because the window tags need +# Accessibility. macOS ties that grant to a *properly signed* app's +# designated requirement, which survives a rebuild, but falls back to +# pinning an ad-hoc-signed app to its exact cdhash — and every build +# produces a new one. So with the ad-hoc default below, each build + +# reinstall silently invalidates the permission, while the app stays +# listed and stays ticked under Privacy & Security → Accessibility. It +# looks granted and isn't, and no amount of toggling that row fixes it: +# the stale entry has to be removed with "−" and the new build added back. +# +# To stop paying that tax on every build, make a self-signed code-signing +# certificate once (Keychain Access → Certificate Assistant → Create a +# Certificate…, type "Code Signing", self-signed) and point this at it: +# +# SHANNONCOAT_SIGN_IDENTITY="My Local Signing Cert" ./build.sh +# +# then re-grant Accessibility one final time. The identity is stable from +# then on, so later builds keep the permission. +if [[ -n "${SHANNONCOAT_SIGN_IDENTITY:-}" ]]; then + # An identity was asked for explicitly, so falling back to ad-hoc would + # quietly reintroduce the very problem it was set to avoid — and a + # release that ships ad-hoc revokes the Accessibility permission of + # everyone who updates. Fail the build instead. + codesign --force --sign "$SHANNONCOAT_SIGN_IDENTITY" \ + --identifier "com.local.shannoncoat" "$APP" \ + || { echo "error: signing with \"$SHANNONCOAT_SIGN_IDENTITY\" failed" >&2; exit 1; } +else + codesign --force --sign - --identifier "com.local.shannoncoat" "$APP" >/dev/null 2>&1 \ + || echo "warning: codesign failed; Accessibility permission may not stick" >&2 +fi + touch "$APP" echo "Built: $APP" echo "Copy it to /Applications (or ~/Applications) to install."