From 3ff91854372f7aa5f3988c5f217d9c102c9822d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Barr=C4=B1=C5=9Fcan=20Aslan?= Date: Mon, 6 Jul 2026 12:49:21 +0300 Subject: [PATCH] fix(release): harden the sign/notarize/appcast pipeline for the first real run A pre-flight audit of the never-yet-run signed pipeline found three defects that would fail a real `v0.1.0` release *after* notarization (the worst place to fail), plus a hazard that could silently ship an unsigned update: - generate_appcast was undiscoverable on a clean CI runner (only .build/ artifacts was searched). Broaden discovery to the DerivedData that `xcodebuild archive` populates, and warm .build via `swift package resolve`. - package.sh emits Capsule-.zip AND .dmg into dist/, and generate_appcast rejects same-version duplicates. Sign the appcast in an isolated dir holding only the update zip; the dmg is a human download Sparkle never sees. - The Sparkle key secret's base64 encoding was ambiguous; release.yml now accepts either the raw key string or a double-encoded secret. - appcast.sh now FAILS if any enclosure lacks sparkle:edSignature, so a key mismatch can't silently publish an update every client rejects. Signing hardening: pin `signingCertificate` in ExportOptions; assert TeamIdentifier + Hardened Runtime on the app and every nested Sparkle component after export; thread SPARKLE_ACCOUNT for local keychain runs; require the Sparkle key in the signing gate; fix a stale entitlements comment. Verified: make check, make release-dry, unit tests of the key-encoding and signature-guard logic, and a functional test of the dmg-isolation + guard against the real appcast.sh. Opus adversarial review: no blockers. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_016e2Q6fgsBk9ML2jwwxRAbf --- .github/workflows/release.yml | 30 +++++++++++++-- App/Capsule.entitlements | 6 ++- Scripts/release/README.md | 16 +++++++- Scripts/release/appcast.sh | 70 +++++++++++++++++++++++++++++++---- Scripts/release/export.sh | 44 ++++++++++++++++++++-- Scripts/release/release.sh | 2 +- 6 files changed, 148 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 132f459..5c43ece 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,8 +46,11 @@ jobs: id: signing env: CERT: ${{ secrets.DEVELOPER_ID_CERT_P12_BASE64 }} + SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} run: | - if [ -n "$CERT" ] && [ -n "${TEAM_ID}" ]; then + # Require the full set (cert + team + Sparkle key) — otherwise the appcast can't be + # signed, so drop cleanly to the unsigned build-only path instead of failing late. + if [ -n "$CERT" ] && [ -n "${TEAM_ID}" ] && [ -n "$SPARKLE_KEY" ]; then echo "enabled=true" >> "$GITHUB_OUTPUT" else echo "enabled=false" >> "$GITHUB_OUTPUT" @@ -78,8 +81,29 @@ jobs: env: SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} run: | - printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | base64 --decode > "$RUNNER_TEMP/sparkle_ed_private_key" - echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV" + keyfile="$RUNNER_TEMP/sparkle_ed_private_key" + # generate_appcast --ed-key-file wants the base64 private-key STRING verbatim (exactly + # what `generate_keys -x` writes: one line of base64). Accept the secret whether it was + # stored as that string OR base64-encoded once more: a raw Sparkle key base64-decodes to + # ~32 binary bytes, whereas a double-encoded secret decodes back to a printable base64 + # line. Pick whichever form yields a valid key string, so the release can't fail (or, + # worse, silently emit an unsigned appcast) on a secret-encoding mismatch. + dec="$RUNNER_TEMP/sparkle_key_decoded" + printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | base64 --decode > "$dec" 2>/dev/null || true + if LC_ALL=C grep -aEq '^[A-Za-z0-9+/]{40,}={0,2}$' "$dec"; then + cp "$dec" "$keyfile" # secret was double-base64-encoded + else + printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$keyfile" # secret stored verbatim + fi + rm -f "$dec" + echo "SPARKLE_ED_KEY_FILE=$keyfile" >> "$GITHUB_ENV" + + - name: Warm the SwiftPM cache (resolves Sparkle's generate_appcast into .build/artifacts) + if: steps.signing.outputs.enabled == 'true' + # Belt-and-suspenders: the archive step also resolves Sparkle into DerivedData (which + # appcast.sh searches), so a transient resolve hiccup must not block a shippable release. + continue-on-error: true + run: swift package resolve - name: Build, sign, notarize, staple, package, appcast if: steps.signing.outputs.enabled == 'true' diff --git a/App/Capsule.entitlements b/App/Capsule.entitlements index 6437826..1741892 100644 --- a/App/Capsule.entitlements +++ b/App/Capsule.entitlements @@ -13,8 +13,10 @@ Sparkle: the UNSANDBOXED updater needs NO extra entitlements. Sparkle's XPC services (Installer/Downloader) and the com.apple.security.temporary-exception.* keys exist only for the SANDBOXED case. Because Capsule is unsandboxed, the in-process updater is used and - this file stays empty. The Sparkle framework is built from source via SwiftPM and signed - with the app under the same Developer ID, so no library-validation exception is required. + this file stays empty. Sparkle ships as a prebuilt, Sparkle-signed binary xcframework (a + SwiftPM binaryTarget); `xcodebuild -exportArchive` re-signs it — and its nested + Autoupdate / Updater.app / XPC services — inside-out with Capsule's own Developer ID, so + library validation passes and no com.apple.security.cs.disable-library-validation is required. --> diff --git a/Scripts/release/README.md b/Scripts/release/README.md index 8318411..dfe773d 100644 --- a/Scripts/release/README.md +++ b/Scripts/release/README.md @@ -51,7 +51,7 @@ Individual steps (each accepts `--dry-run`): | 2 | `export.sh` | `make export` | `xcodebuild -exportArchive` (Developer ID) → signed `dist/Capsule.app` (inside-out, incl. Sparkle; Hardened Runtime) | | 3 | `notarize.sh` | `make notarize` | zip + `notarytool submit --wait` + `stapler staple` | | 4 | `package.sh` | `make package` | `dist/Capsule-.zip` (Sparkle) + `dist/Capsule-.dmg` | -| 5 | `appcast.sh` | `make appcast` | Sparkle `generate_appcast` signs the zip + writes `appcast.xml` (with `RELEASE_DOWNLOAD_URL_PREFIX`) | +| 5 | `appcast.sh` | `make appcast` | Sparkle `generate_appcast` signs the zip (in an isolated dir — the `.dmg` is kept out so Sparkle doesn't reject the same-version pair) + writes `appcast.xml` (with `RELEASE_DOWNLOAD_URL_PREFIX`), then **fails if any enclosure is unsigned** | ## Environment (local runs) @@ -60,8 +60,9 @@ Individual steps (each accepts `--dry-run`): | `TEAM_ID` | signed runs | Apple Developer Team ID | | `NOTARY_PROFILE` | signed runs | `notarytool` keychain profile name | | `RELEASE_DOWNLOAD_URL_PREFIX` | CI-set | prepended to the appcast's enclosure filenames — the tag's release-download URL. Empty → filenames stay relative to the appcast. | -| `SPARKLE_BIN` | optional | dir containing `generate_appcast` / `generate_keys` | +| `SPARKLE_BIN` | optional | dir containing `generate_appcast` / `generate_keys`. If unset, `appcast.sh` searches `.build/artifacts`, `DerivedData/SourcePackages/artifacts`, and the global Xcode DerivedData (where `xcodebuild archive` resolves Sparkle), then `PATH`. | | `SPARKLE_ED_KEY_FILE` | optional | private-key file; else the login keychain | +| `SPARKLE_ACCOUNT` | optional | keychain account for the private key when `SPARKLE_ED_KEY_FILE` is unset (this repo's key was generated under `capsule-native`; without it a local run signs with the wrong/default account → unsigned enclosures) | | `DIST_DIR` | optional | output dir (default `dist/`) | ## Cutting a release @@ -77,6 +78,17 @@ Individual steps (each accepts `--dry-run`): - **Version comes from the app, not the tag.** Artifact names and the Sparkle appcast use `CFBundleShortVersionString` from `App/Info.plist`; the tag guard just enforces they agree. +- **Unsigned appcasts can't ship.** If the signing key's public half doesn't match the app's + `SUPublicEDKey`, `generate_appcast` only *warns* and writes an enclosure with no + `sparkle:edSignature` (every client then rejects the update). `appcast.sh` now **fails the + build** unless every enclosure is signed, so this can't slip out silently. +- **Sparkle key secret is encoding-agnostic.** `release.yml` accepts `SPARKLE_ED_PRIVATE_KEY` + whether it was stored as Sparkle's raw base64 key string or base64-encoded once more, and + writes the correct `--ed-key-file` either way. +- **`generate_appcast` discovery on CI.** The clean runner has no `.build/`; the tool is resolved + into DerivedData by the `xcodebuild archive` step (and a `swift package resolve` step warms + `.build/artifacts`), and `appcast.sh` searches both — so the appcast step no longer dies for + want of the binary. - **Notary password on argv.** `notarize.sh` / `release.yml` pass the app-specific password to `notarytool store-credentials --password` (the API has no stdin form). It's masked and never echoed; on the ephemeral, single-tenant `macos-26` runner nothing else can read the process diff --git a/Scripts/release/appcast.sh b/Scripts/release/appcast.sh index bb7b408..b608474 100755 --- a/Scripts/release/appcast.sh +++ b/Scripts/release/appcast.sh @@ -10,19 +10,32 @@ # SUFeedURL (App/Info.plist) at wherever you host the resulting appcast.xml. # # Optional env: SPARKLE_BIN (dir containing generate_appcast), SPARKLE_ED_KEY_FILE (a private -# key file, if you don't use the keychain). +# key file, if you don't use the keychain), SPARKLE_ACCOUNT (keychain account for the private +# key when SPARKLE_ED_KEY_FILE is unset; defaults to the account the key was generated under). source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" parse_common_flags "$@" -# Locate generate_appcast: explicit SPARKLE_BIN, then the resolved SwiftPM artifact, then PATH. +# Locate generate_appcast. Sparkle's tools ship inside its SwiftPM binary artifact; where that +# lands depends on how the package was resolved: +# • `swift build`/`swift package resolve` → $REPO_ROOT/.build/artifacts +# • `xcodebuild -derivedDataPath DerivedData` → $REPO_ROOT/DerivedData/SourcePackages/artifacts +# • `xcodebuild archive` (default derived data) → ~/Library/Developer/Xcode/DerivedData/*/SourcePackages/artifacts +# The release pipeline archives with xcodebuild BEFORE this step, so on a clean CI runner the +# tool exists only under the global DerivedData — search all three (plus $SPARKLE_BIN / PATH). find_generate_appcast() { if [ -n "${SPARKLE_BIN:-}" ] && [ -x "$SPARKLE_BIN/generate_appcast" ]; then echo "$SPARKLE_BIN/generate_appcast"; return 0 fi - local hit - hit="$(find "$REPO_ROOT/.build/artifacts" -name generate_appcast -type f 2>/dev/null | head -1 || true)" - if [ -n "$hit" ]; then echo "$hit"; return 0; fi + local root hit + for root in \ + "$REPO_ROOT/.build/artifacts" \ + "$REPO_ROOT/DerivedData/SourcePackages/artifacts" \ + "$HOME/Library/Developer/Xcode/DerivedData"; do + [ -d "$root" ] || continue + hit="$(find "$root" -name generate_appcast -type f 2>/dev/null | head -1 || true)" + if [ -n "$hit" ]; then echo "$hit"; return 0; fi + done if command -v generate_appcast >/dev/null 2>&1; then command -v generate_appcast; return 0; fi return 1 } @@ -47,12 +60,53 @@ if [ -n "${RELEASE_DOWNLOAD_URL_PREFIX:-}" ]; then log "Enclosure URL prefix: $RELEASE_DOWNLOAD_URL_PREFIX" fi +# generate_appcast scans a WHOLE directory and rejects two archives that carry the same bundle +# version — and package.sh emits both Capsule-.zip AND Capsule-.dmg into $DIST_DIR. Run +# generate_appcast over a dedicated dir holding ONLY the Sparkle update zip(s), then move the +# result back. (The .dmg is a human download only; Sparkle never sees it.) Note: this dir is +# reset each run and holds only the current zip, so appcasts are single-item with no deltas — +# fine for full-zip updates; wiring delta/version history would mean retaining prior zips here. +APPCAST_SRC="$DIST_DIR/appcast-src" +run rm -rf "$APPCAST_SRC" +run mkdir -p "$APPCAST_SRC" +if [ "$DRY_RUN" != "1" ]; then + shopt -s nullglob + zips=("$DIST_DIR"/Capsule-*.zip) + shopt -u nullglob + [ ${#zips[@]} -gt 0 ] || die "no Capsule-*.zip in $DIST_DIR — run package.sh first" + for z in "${zips[@]}"; do run cp "$z" "$APPCAST_SRC/"; done +fi + log "Signing artifacts + generating appcast with: $GEN" if [ -n "${SPARKLE_ED_KEY_FILE:-}" ]; then - run "$GEN" --ed-key-file "$SPARKLE_ED_KEY_FILE" ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} "$DIST_DIR" + run "$GEN" --ed-key-file "$SPARKLE_ED_KEY_FILE" ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} "$APPCAST_SRC" else - # No key file → generate_appcast reads the private key from the login keychain. - run "$GEN" ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} "$DIST_DIR" + # No key file → generate_appcast reads the private key from the login keychain. Sparkle looks + # up the DEFAULT "ed25519" account unless told otherwise; this key was generated under a named + # account (see App/Info.plist), so pass it through or the run silently emits UNSIGNED enclosures. + ACCOUNT_ARGS=() + [ -n "${SPARKLE_ACCOUNT:-}" ] && ACCOUNT_ARGS=(--account "$SPARKLE_ACCOUNT") + run "$GEN" ${ACCOUNT_ARGS[@]+"${ACCOUNT_ARGS[@]}"} ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} "$APPCAST_SRC" +fi +[ "$DRY_RUN" = "1" ] || [ -f "$APPCAST_SRC/appcast.xml" ] || die "generate_appcast wrote no appcast.xml — nothing was signed." +run mv "$APPCAST_SRC/appcast.xml" "$DIST_DIR/appcast.xml" + +# Guard the silent-unsigned-update failure mode: if the signing key's public half does not match +# the app's embedded SUPublicEDKey, generate_appcast only WARNS, exits 0, and writes enclosures +# with NO sparkle:edSignature — which every client rejects. Refuse to publish such an appcast. +if [ "$DRY_RUN" != "1" ]; then + APPCAST="$DIST_DIR/appcast.xml" + [ -f "$APPCAST" ] || die "generate_appcast produced no appcast.xml" + # `|| true`: zero matches makes grep exit 1, which under `set -euo pipefail` would abort the + # script before the checks below — swallow it so the guard (not set -e) reports the problem. + n_enc="$( { grep -o ' — nothing to publish." + if [ "${n_sig:-0}" -lt "${n_enc:-0}" ]; then + pub="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$REPO_ROOT/App/Info.plist" 2>/dev/null || echo '?')" + die "appcast.xml has $n_enc enclosure(s) but only $n_sig EdDSA signature(s) — refusing to publish an UNSIGNED update (Sparkle clients reject it). The signing key's public half must equal App/Info.plist SUPublicEDKey ($pub)." + fi + log "Verified $n_sig/$n_enc enclosure(s) carry an EdDSA signature." fi log "appcast written → $DIST_DIR/appcast.xml" diff --git a/Scripts/release/export.sh b/Scripts/release/export.sh index 51aa6df..5ad0b6c 100755 --- a/Scripts/release/export.sh +++ b/Scripts/release/export.sh @@ -9,7 +9,8 @@ # Runtime and a secure timestamp, which is exactly what notarization requires. Doing it this # way (instead of hand-rolled `codesign --deep`) is Apple's recommended path. # -# Required env (real run): DEVELOPER_ID_APP or TEAM_ID. +# Required env (real run): TEAM_ID (the export selects the team's "Developer ID Application" +# certificate; TEAM_ID also scopes notarization). source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" parse_common_flags "$@" @@ -31,6 +32,9 @@ if [ "$DRY_RUN" != "1" ]; then method developer-id teamID ${TEAM_ID} signingStyle manual + + signingCertificate Developer ID Application destination export @@ -50,10 +54,42 @@ run xcodebuild -exportArchive \ run rm -rf "$DIST_DIR/Capsule.app" run cp -R "$EXPORT_DIR/Capsule.app" "$DIST_DIR/Capsule.app" +# assert_signed