Skip to content

Refuse to sign a release with the wrong Sparkle key - #23

Merged
wouterdebie merged 1 commit into
mainfrom
sparkle-key-guard
Sep 17, 2026
Merged

wouterdebie merged 1 commit into
mainfrom
sparkle-key-guard

Conversation

@wouterdebie

Copy link
Copy Markdown
Owner

SUPublicEDKey is baked into every shipped bundle and Sparkle reads exactly one of them — no key list, no secondary key, no overlap window. Signing a release with a key the bundle doesn't trust doesn't degrade anything; it produces an update every installed copy rejects, and Sparkle has no mechanism to route those users back to a working item. They're stranded until they reinstall by hand.

The accidental route is specific and live: the key sits in the login keychain under account dev.wouter.dontmiss, not the tooling's default ed25519.

$ security find-generic-password -s "https://sparkle-project.org" -a "ed25519"
security: The specified item could not be found in the keychain.

So generate_keys concludes no key exists, reports no conflict, and silently mints a new one. A key restored to the wrong account on a new machine reaches the same place, as does a mistyped secret.

What this adds. scripts/check-signing-key.sh derives the signing key's public half and compares it to the bundle's SUPublicEDKey, and release.yml runs it before the key is used for anything.

Sparkle stores a 32-byte seed, so this needs actual curve arithmetic. /usr/bin/openssl is LibreSSL and can't do it; Homebrew's OpenSSL 3 can, but depending on it being present on a runner is a worse bet than 20 lines of RFC 8032. The script verifies its own arithmetic against the RFC's test vector before judging anything, so a reported mismatch is a statement about the key and not about the script.

CI exercises both directions without the real key — a random key must be refused, and a bundle stamped with that key's public half must be accepted. A guard that never refuses is worse than no guard, because it reads as protection.

Verified locally against the real key: accepts it, refuses a random one with exit 1, and --print-public on the 1Password key reproduces the SUPublicEDKey in the app currently installed in /Applications.

🤖 Generated with Claude Code

SUPublicEDKey is baked into every shipped bundle, and Sparkle reads
exactly one of them -- no key list, no secondary key, no overlap window.
A release signed with a key the bundle does not trust is not degraded,
it is rejected by every installed copy, and there is nothing that routes
those users back to a working item. They are stranded until they
reinstall by hand.

The accidental route is specific: the key lives in the login keychain
under account dev.wouter.dontmiss, not the tooling's default ed25519,
so generate_keys finds nothing, reports no conflict, and mints a new
key. A key restored to the wrong account on a new machine gets there
too, as does a mistyped secret.

The release job now derives the signing key's public half and compares
it to the bundle's SUPublicEDKey before the key is used for anything.
Sparkle stores a 32-byte seed, so this needs real curve arithmetic:
LibreSSL, which is what /usr/bin/openssl is on macOS, cannot do it, and
depending on a Homebrew OpenSSL being on the runner is a worse bet than
20 lines of RFC 8032. The script checks its own arithmetic against the
RFC's test vector first, so a reported mismatch is a statement about
the key rather than about the script.

CI exercises both directions without holding the real key: a random key
must be refused, and a bundle stamped with that key's public half must
be accepted. A guard that never refuses is worse than no guard, since
it reads as protection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@wouterdebie
wouterdebie merged commit 30784fc into main Sep 17, 2026
1 check passed
@wouterdebie
wouterdebie deleted the sparkle-key-guard branch September 17, 2026 18:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant