feat(packaging/bazzite): the sysext feed is signed, and the client refuses one that isn't

`punktfunk-sysext` checked the SHA256 of every image it downloaded, which sounds
like verification but isn't: SHA256SUMS lives on the same registry as the images
it describes, so anything able to replace an image could replace its checksum in
the same request. The checksum only ever proved the download wasn't corrupted.

Each feed now carries SHA256SUMS.asc, a detached OpenPGP signature over the
manifest, and the client verifies it before believing a line of it. The key is
packages@unom.io (AF245C506F4E4763) — the same one that already signs our RPMs,
so boxes have one key to trust and we have one key to rotate. Its public half is
baked into the script rather than fetched from the feed, because a key you fetch
from the thing you're authenticating authenticates nothing; gpg (present on
Bazzite) does the verifying against a throwaway keyring holding only that key, so
"good signature" and "signed by us" are the same statement.

Rollout: stable feeds only publish on a tag, so a `--seal` mode re-signs an
existing manifest without rebuilding an image, and every rpm.yml run seals the
OTHER channel of its Fedora major too. Canary pushes are frequent, so all live
feeds seal within a day of this landing and a key rotation propagates without
republishing anything. Until a feed is sealed the client refuses it and says so,
naming PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 as the informed way through.

Two things testing changed. The baked-key fingerprint check compared against an
empty string — the armor block is a single-quoted shell literal, so the extracted
range carried `FEED_KEY='` on its first line and gpg saw no armor at all; every
publish would have "mismatched" and, on a tag, failed the release. And `status`
captured fetch_manifest's stderr but only printed it on failure, swallowing the
ALLOW_UNSIGNED warning precisely when someone was running unverified.

Verified end to end on Bazzite 44 against a file:// feed with a throwaway key:
unsigned feed refused; ALLOW_UNSIGNED=1 proceeds and says so; wrong signer
rejected; tampered manifest rejected; good signature accepted; `update` exits 1
before touching anything on a bad feed. Publisher side: signs when the baked key
matches, refuses on mismatch, refuses with no key, and its signature round-trips
through the real client. shellcheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-29 12:12:21 +02:00
co-authored by Claude Opus 5
parent 7b053a4a39
commit 93902ff60e
6 changed files with 247 additions and 24 deletions
+9
View File
@@ -88,6 +88,15 @@ curl --user "<user>:<write:package-PAT>" --upload-file packaging/rpm/RPM-GPG-KEY
Rotating the key means a new generic-registry version (bump `punktfunk-keys/1``/2` and the
`gpgkey=` URL), since the registry rejects re-uploading an existing file.
**This key also signs the Bazzite sysext feed**, and a third copy of its public half is baked into
`packaging/bazzite/punktfunk-sysext.sh` (`FEED_KEY=`) — that script is bootstrapped by `curl` on
machines that have nothing installed yet, so it can't fetch the key from the thing it's
authenticating. A rotation must update **all three**: the CI secret, this directory's
`RPM-GPG-KEY-punktfunk` (+ its registry upload), and `FEED_KEY`. `publish-sysext-feed.sh` compares
its signing key's fingerprint against `FEED_KEY` and refuses to sign on a mismatch, so forgetting
the third one fails the publish instead of stranding every Bazzite box in front of a feed it
can't verify.
After reboot, as the desktop user:
```sh