fix(release): Sparkle appcast fails closed when the signing key is not the app's SUPublicEDKey
Fixes an internal report: the Sparkle appcast fallback signed the feed with a key the app does not trust.
What was wrong
scripts/sparkle_generate_appcast.sh ran sign_update -p --ed-key-file and injected the signature whenever generate_appcast left the feed unsigned. generate_appcast leaves it unsigned exactly when the private key's public key is not the app's SUPublicEDKey, so a wrong key produced a feed that looked signed and passed the script's only check (a grep for sparkle:edSignature), while every installed app rejects the update. The fallback also added a second length attribute to an enclosure that already had one, which makes the feed unparsable, and used stat -f%z (macOS only).
Fix
- Before anything is generated (and before delta prebuilding), derive the public key of
SPARKLE_PRIVATE_KEY(scripts/ci/dev_app_linux.py sparkle-public-keythrough openssl, falling back toscripts/derive_sparkle_public_key.swiftwhere openssl is LibreSSL), mount the DMG copy read-only and read the app'sSUPublicEDKey. Fail closed on a mismatch, a missingSUPublicEDKey, or a key that cannot be derived. - Fallback: verify the injected signature with
sign_update --verifyand replace the enclosure'slengthwith the DMG's exact size instead of adding a duplicate. - Final check: parse the feed and require the DMG's single enclosure to carry an
edSignatureand alengthequal to the DMG's byte size. A failing feed is removed from the output path.
Callers checked (all run on macOS, pass the DMG whose app the key must match, and need no change): release.yml, nightly.yml, auto-resume-nightly-notarization.yml (4 calls), repair-v0-64-25-helper-rpaths.yml, scripts/build-sign-upload.sh, scripts/ci/nightly-steps/recover-generate-appcast.sh, scripts/ci/generate-nightly-deltas.sh. No environment, secret or workflow permission changes.
Red/green
bash tests/test_sparkle_generate_appcast_no_deltas.sh (runs on Linux with stub generate_appcast, sign_update and hdiutil; the keys are the RFC 8032 Ed25519 test vectors). New cases: mismatched key (signed and unsigned generator), app without SUPublicEDKey, a non-Ed25519 private key, a wrong enclosure length, a fallback signature that fails --verify, and the matching-key fallback (one length, the DMG's size, signature verified).
- Base (test commit d996772):
FAIL: mismatch: missing 'does not match the app's SUPublicEDKey'(the fallback signed the feed). - Head: all three
PASSlines.
Observed / Not run
Observed: the test above red at the test commit and green at the head on Linux bash 5.2; prepush-gate.py reports no new violations. Not run: a real macOS run of hdiutil attach and the real Sparkle 2.8.1 sign_update --verify (the fallback path only runs when generate_appcast did not sign, which the up-front key check now makes rare).
Risk
High tier (release/signing). A release, nightly or repair run whose private key does not match the built app now fails at appcast generation instead of publishing a feed nobody can install. Matching keys (the normal case, since every caller embeds the key derived from the same private key) behave as before, plus one read-only DMG mount and a key derivation (openssl, or one swift script run on hosts with LibreSSL).
Changelog
none
Agent session: agent session
š¤ Generated with Claude Code
![View with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/view-with-codesmith-dark-v3-3x.png)
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Fixes the Sparkle appcast script signing the feed with a key the shipped app does not trust, which published updates every installed app would reject.
The script now derives the public key of SPARKLE_PRIVATE_KEY, mounts the DMG read-only, and compares it to the app's SUPublicEDKey before generating anything. It fails closed on a mismatch, a missing SUPublicEDKey, or an underivable key. The sign_update fallback now verifies the injected signature and replaces the enclosure's length instead of adding a duplicate attribute, which made the feed unparsable. A final check parses the feed and requires the DMG's enclosure to be signed with the DMG's exact byte size, removing failing feeds from the output path.
The nightly deltas test fixture now mounts this build's DMG through a fake hdiutil to serve the key check and writes each archive's real enclosure length. Callers need no changes; matching keys (the normal case) behave as before, plus one read-only DMG mount and a key derivation.