fix(release): Sparkle appcast fails closed when the signing key is not the app's SUPublicEDKey

Merged
TE
7d ago
+207-20

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-key through openssl, falling back to scripts/derive_sparkle_public_key.swift where openssl is LibreSSL), mount the DMG copy read-only and read the app's SUPublicEDKey. Fail closed on a mismatch, a missing SUPublicEDKey, or a key that cannot be derived.
  • Fallback: verify the injected signature with sign_update --verify and replace the enclosure's length with 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 edSignature and a length equal 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 PASS lines.

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 Autofix with [code]smith
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.