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
+10 -1
View File
@@ -49,7 +49,8 @@ sudo bash punktfunk-sysext.sh install
```
This downloads the newest image for your Fedora base (host + tray + **web console**,
SHA-256-verified from the feed `…/packages/unom/generic/punktfunk-sysext/f<ver>[-canary]/`),
SHA-256-verified against a signed manifest from the feed
`…/packages/unom/generic/punktfunk-sysext/f<ver>[-canary]/`),
installs it as `/var/lib/extensions/punktfunk.raw`, merges it, and immediately applies what the
RPM scriptlets would have (udev reload, sysctl) plus the two `/etc` files a sysext can't carry
(the gamescope-session drop-in and the tray autostart entry, staged under
@@ -63,6 +64,14 @@ sudo punktfunk-sysext remove # unmerge + delete; ~/.config/punktfunk is left
Details worth knowing:
- **The feed is signed.** Each feed carries `SHA256SUMS` plus a detached OpenPGP signature
`SHA256SUMS.asc` from `packages@unom.io` (`AF245C506F4E4763`) — the same key that signs our RPMs.
`punktfunk-sysext` verifies that signature, with the public key baked into the script, *before*
it believes the manifest, and refuses a feed it can't verify. The checksums alone never proved
authorship: they sit on the same registry as the images they describe, so whatever could replace
an image could replace its checksum in the same breath. If you are on a feed published before
signing existed, it is sealed on the next publish to that Fedora major; to install from it
meanwhile — accepting that the image is unauthenticated — set `PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1`.
- The image embeds `ID=fedora` + `VERSION_ID` (matched through Bazzite's `ID_LIKE`), so after a
**major Bazzite rebase** (F43 → F44) the old image is **refused** instead of merging
soname-broken binaries — `punktfunk-sysext update` then fetches the image built for the new
+100 -12
View File
@@ -1,31 +1,108 @@
#!/usr/bin/env bash
# Publish a punktfunk sysext image into its feed on the Gitea generic package registry —
# called by .gitea/workflows/rpm.yml after the RPM publish. A feed is one fixed URL
# (…/punktfunk-sysext/<feed>/) holding versioned .raw files plus a SHA256SUMS manifest;
# punktfunk-sysext(8) on the boxes reads SHA256SUMS to find + verify the newest image
# (the layout is also exactly what systemd-sysupdate's url-file source expects, so a
# .transfer feed can be added later without re-publishing anything).
# (…/punktfunk-sysext/<feed>/) holding versioned .raw files plus a SHA256SUMS manifest and its
# detached OpenPGP signature SHA256SUMS.asc; punktfunk-sysext(8) on the boxes verifies that
# signature, then uses the manifest to find + check the newest image (the layout is also exactly
# what systemd-sysupdate's url-file source expects, so a .transfer feed can be added later
# without re-publishing anything).
#
# Signing uses RPM_GPG_PRIVATE_KEY — the SAME packages@unom.io key that signs the RPMs, so boxes
# have one key to trust and we have one key to rotate. Without checksums-plus-signature the feed
# was self-certifying: SHA256SUMS sits on the same registry as the images it describes, so whoever
# could swap an image could swap its checksum in the same breath.
#
# Usage: TOKEN=… [KEEP=6] bash publish-sysext-feed.sh <feed> <image.raw>
# TOKEN=… bash publish-sysext-feed.sh --seal <feed>
# <feed> e.g. f43, f43-canary, f44 (Fedora major x channel)
# KEEP newest images to keep in the feed; 0/unset-for-stable = keep all
# Env: REGISTRY (git.unom.io), OWNER (unom), TOKEN (write:package PAT), CURL_USER (login name)
# --seal re-sign a feed's EXISTING manifest without publishing an image. For feeds published
# before signing existed, and after a key rotation. Idempotent.
# Env: REGISTRY (git.unom.io), OWNER (unom), TOKEN (write:package PAT), CURL_USER (login name),
# RPM_GPG_PRIVATE_KEY (armored private key; absent => unsigned, fatal on a v* tag)
set -euo pipefail
FEED="${1:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
RAW="${2:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
[ -f "$RAW" ] || { echo "no such image: $RAW" >&2; exit 1; }
SEAL=0
if [ "${1:-}" = --seal ]; then
SEAL=1; FEED="${2:?usage: publish-sysext-feed.sh --seal <feed>}"; RAW=""
else
FEED="${1:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
RAW="${2:?usage: publish-sysext-feed.sh <feed> <image.raw>}"
[ -f "$RAW" ] || { echo "no such image: $RAW" >&2; exit 1; }
fi
REGISTRY="${REGISTRY:-git.unom.io}"
OWNER="${OWNER:-unom}"
KEEP="${KEEP:-0}"
AUTH=(--user "${CURL_USER:-enricobuehler}:${TOKEN:?TOKEN (write:package PAT) required}")
BASE="https://$REGISTRY/api/packages/$OWNER/generic/punktfunk-sysext/$FEED"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORK="$(mktemp -d)"; trap 'rm -rf "$WORK"' EXIT
SUMS="$WORK/SHA256SUMS"
SIG="$WORK/SHA256SUMS.asc"
# sign_manifest — detached-sign $SUMS into $SIG with RPM_GPG_PRIVATE_KEY. Prints nothing and
# returns 1 if no key is available; the caller decides whether that is survivable.
sign_manifest() {
[ -n "${RPM_GPG_PRIVATE_KEY:-}" ] || return 1
local home keyid baked
home="$(mktemp -d)"; chmod 700 "$home"
# Loopback pinentry for the same reason sign-rpms.sh needs it: no TTY in CI.
printf 'pinentry-mode loopback\n' > "$home/gpg.conf"
printf 'allow-loopback-pinentry\n' > "$home/gpg-agent.conf"
printf '%s' "$RPM_GPG_PRIVATE_KEY" | GNUPGHOME="$home" gpg --batch --quiet --import
keyid="$(GNUPGHOME="$home" gpg --list-secret-keys --with-colons | awk -F: '/^fpr:/{print $10; exit}')"
# A feed signed by a key the client script doesn't carry is a feed nobody can install from, and
# the failure would only surface on someone's Bazzite box. Compare fingerprints here instead: the
# baked-in public key in punktfunk-sysext.sh must be the key we are about to sign with.
# The armor block is a shell single-quoted literal in that script, so the range's first and last
# lines carry the `FEED_KEY='` prefix and the closing quote — strip them or gpg sees no armor at
# all and hands back an empty fingerprint, which would look like a mismatch on every publish.
baked="$(sed -n '/BEGIN PGP PUBLIC KEY BLOCK/,/END PGP PUBLIC KEY BLOCK/p' "$HERE/punktfunk-sysext.sh" \
| sed "s/^FEED_KEY='//; s/'\$//" \
| GNUPGHOME="$home" gpg --batch --quiet --with-colons --import-options show-only --import 2>/dev/null \
| awk -F: '/^fpr:/{print $10; exit}')"
if [ -z "$baked" ] || [ "$keyid" != "$baked" ]; then
echo "signing key $keyid does not match the key baked into punktfunk-sysext.sh ($baked)" >&2
echo "-> rotate BOTH (packaging/rpm/README.md), or clients cannot verify this feed." >&2
rm -rf "$home"; return 1
fi
GNUPGHOME="$home" gpg --batch --yes --armor --detach-sign --local-user "$keyid" \
--output "$SIG" "$SUMS"
rm -rf "$home"
echo "signed SHA256SUMS with $keyid"
}
# require_signature — on a v* tag an unsigned feed is a hard stop, exactly like sign-rpms.sh:
# clients refuse unsigned feeds, so publishing one would strand every box on the stable channel.
require_signature() {
case "${GITHUB_REF:-}" in
refs/tags/v*)
echo "release build (${GITHUB_REF}) but the sysext feed could not be signed — aborting." >&2
exit 1 ;;
esac
# Deliberately mode-neutral wording: in --seal mode nothing is published at all, and a log line
# claiming otherwise is the kind of thing that costs an hour six months from now.
echo "WARNING: $FEED left UNSIGNED (no usable RPM_GPG_PRIVATE_KEY); clients will refuse it." >&2
}
# --seal: re-sign whatever manifest the feed already has, no image, no pruning.
if [ "$SEAL" = 1 ]; then
curl -fsS "${AUTH[@]}" -o "$SUMS" "$BASE/SHA256SUMS" \
|| { echo "no SHA256SUMS at $BASE — nothing to seal" >&2; exit 1; }
sign_manifest || require_signature
if [ -f "$SIG" ]; then
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS.asc" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SIG" "$BASE/SHA256SUMS.asc"
echo "sealed $BASE ($(wc -l <"$SUMS") image(s))"
fi
exit 0
fi
FNAME="$(basename "$RAW")"
SHA="$(sha256sum "$RAW" | cut -d' ' -f1)"
# Merge into the existing manifest: drop any prior line for this filename, append ours.
SUMS="$(mktemp)"; trap 'rm -f "$SUMS"' EXIT
curl -fsS "${AUTH[@]}" "$BASE/SHA256SUMS" 2>/dev/null | grep -v " $FNAME\$" > "$SUMS" || true
printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS"
@@ -38,13 +115,24 @@ if [ "$KEEP" -gt 0 ]; then
done
fi
# Upload order keeps consumers consistent: image first, then the manifest referencing it,
# then prune deletions (already absent from the manifest). Delete-before-put makes workflow
# re-runs idempotent (the registry 409s on duplicate filenames; first-publish 404s are fine).
# Sign the finished manifest BEFORE anything is uploaded — a signing failure on a release must
# abort while the live feed is still whole, not halfway through being replaced.
sign_manifest || require_signature
# Upload order keeps consumers consistent: image first, then the manifest referencing it, then its
# signature, then prune deletions (already absent from the manifest). A client that catches the
# window between the manifest and its signature sees an unsigned feed and refuses — it does not see
# a manifest vouched for by a stale signature, which is why the .asc goes last and never first.
# Delete-before-put makes workflow re-runs idempotent (the registry 409s on duplicate filenames;
# first-publish 404s are fine).
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$FNAME" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$RAW" "$BASE/$FNAME"
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS"
if [ -f "$SIG" ]; then
curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/SHA256SUMS.asc" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SIG" "$BASE/SHA256SUMS.asc"
fi
for f in "${PRUNE[@]:-}"; do
[ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; }
done
+92 -9
View File
@@ -16,10 +16,17 @@
# sudo punktfunk-sysext update | status | remove
#
# Feed: the Gitea generic package registry, one feed per Fedora major x channel
# (…/punktfunk-sysext/f43/, f43-canary, f44, …), each a SHA256SUMS + versioned .raw files —
# published by .gitea/workflows/rpm.yml from the same RPMs the (legacy) layering path uses.
# The image pins ID=fedora + VERSION_ID, so after a major OS rebase the old image is refused
# (…/punktfunk-sysext/f43/, f43-canary, f44, …), each a SHA256SUMS + SHA256SUMS.asc + versioned
# .raw files — published by .gitea/workflows/rpm.yml from the same RPMs the (legacy) layering path
# uses. The image pins ID=fedora + VERSION_ID, so after a major OS rebase the old image is refused
# (not merged broken) and `punktfunk-sysext update` re-resolves against the new release.
#
# Trust: SHA256SUMS carries a detached OpenPGP signature (SHA256SUMS.asc) from packages@unom.io —
# the same key that signs our RPMs — and this script verifies it before believing a word of the
# manifest. The checksums alone could never have done that: they live on the same registry as the
# images they describe, so anything able to replace an image could replace its checksum too. The
# public key is baked in below rather than fetched, because a key fetched from the thing you are
# authenticating authenticates nothing.
set -euo pipefail
REGISTRY="${PUNKTFUNK_SYSEXT_REGISTRY:-https://git.unom.io/api/packages/unom/generic/punktfunk-sysext}"
@@ -29,6 +36,21 @@ IMG="$EXT_DIR/punktfunk.raw"
SIDECAR="$EXT_DIR/.punktfunk.version"
MARKER=/usr/lib/extension-release.d/extension-release.punktfunk
ETC_SRC=/usr/share/punktfunk/etc
PF_TMP="$(mktemp -d)"; trap 'rm -rf "$PF_TMP"' EXIT
# The feed's signing key: punktfunk packages <packages@unom.io>, AF245C506F4E4763. Identical to
# packaging/rpm/RPM-GPG-KEY-punktfunk — ONE key signs both the RPMs and this feed, so rotating it
# means updating both copies (the rotation runbook in packaging/rpm/README.md says so, and
# publish-sysext-feed.sh refuses to sign if the two ever disagree).
FEED_KEY='-----BEGIN PGP PUBLIC KEY BLOCK-----
mDMEai/2eRYJKwYBBAHaRw8BAQdAFxLGvh8wvzES9ylmxT4gy1i58EituotPyZwt
z+y9rbC0JXB1bmt0ZnVuayBwYWNrYWdlcyA8cGFja2FnZXNAdW5vbS5pbz6IkAQT
FgoAOBYhBDG6uOY81eoQ6beahK8kXFBvTkdjBQJqL/Z5AhsjBQsJCAcCBhUKCQgL
AgQWAgMBAh4BAheAAAoJEK8kXFBvTkdj1QsBAM0sI/qUzGEbuC2Zrk36QQBrUu/9
sy5uhYGZD6lMJ4uZAQC7W81H2gHlTDTA2Nq35HKW9IOU+Ll2c9fqa7fAIKf9Bg==
=e4Az
-----END PGP PUBLIC KEY BLOCK-----'
usage() {
sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p'
@@ -47,12 +69,61 @@ feed_url() {
echo "$REGISTRY/f$(os_version_id)$suffix"
}
# latest -> "VERSION FILENAME SHA256" from the feed's SHA256SUMS (highest by version sort).
# verify_manifest SUMS SIG -> 0 iff SIG is a good detached signature over SUMS by FEED_KEY.
# A throwaway keyring holding exactly our one key, so "good signature" and "signed by us" are the
# same statement — any other signer comes back NO_PUBKEY, and gpg exits non-zero.
# Spelled out with plain `if`s rather than `cond && action`: under `set -e` a failing test at the
# end of an && list is a trap that only bites on the path nobody exercises (here: a corrupt
# FEED_KEY), and this function must never abort the script — its whole job is to return a verdict.
verify_manifest() {
local home rc=1
home="$(mktemp -d)"; chmod 700 "$home"
if printf '%s\n' "$FEED_KEY" | GNUPGHOME="$home" gpg --batch --quiet --import 2>/dev/null; then
if GNUPGHOME="$home" gpg --batch --quiet --verify "$2" "$1" 2>/dev/null; then rc=0; fi
fi
rm -rf "$home"
return "$rc"
}
# fetch_manifest -> download the feed's SHA256SUMS into $PF_TMP and verify its signature.
# Returns non-zero (having said why) rather than exiting, so `status` can report a bad feed
# instead of dying on it; install/update turn that into a hard stop.
fetch_manifest() {
local feed sums sig
feed="$(feed_url)"
sums="$PF_TMP/SHA256SUMS"; sig="$PF_TMP/SHA256SUMS.asc"
curl -fsSL -o "$sums" "$feed/SHA256SUMS" || { echo "cannot reach the feed $feed" >&2; return 1; }
if [ "${PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED:-0}" = 1 ]; then
echo "!! PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1 — the feed manifest is NOT being verified." >&2
return 0
fi
# curl's own "404"/"could not open file" is noise here — a missing signature is an expected
# state with a much better explanation below, so swallow it and say the useful thing instead.
if ! curl -fsSL -o "$sig" "$feed/SHA256SUMS.asc" 2>/dev/null; then
echo "!! the feed $feed has no SHA256SUMS.asc — refusing to install from an unsigned feed." >&2
echo "!! (a feed published before signing existed; it is sealed on the next publish. To install" >&2
echo "!! from it anyway, knowing the images are unauthenticated: PUNKTFUNK_SYSEXT_ALLOW_UNSIGNED=1)" >&2
return 1
fi
if ! command -v gpg >/dev/null 2>&1; then
echo "!! gpg not found — cannot verify the feed signature. Install gnupg2." >&2
return 1
fi
if ! verify_manifest "$sums" "$sig"; then
echo "!! the feed's SHA256SUMS is NOT signed by packages@unom.io (AF245C506F4E4763)." >&2
echo "!! Someone has tampered with the feed, or the signing key was rotated and this script is" >&2
echo "!! older than the rotation. Do not install; re-download punktfunk-sysext.sh and retry." >&2
return 1
fi
return 0
}
# latest -> "VERSION FILENAME SHA256" for the newest image in the VERIFIED manifest (version sort).
# Call fetch_manifest first — reading $PF_TMP/SHA256SUMS directly is what keeps the signature
# check off the subshell path, where an `exit` would have vanished into a command substitution.
latest() {
local feed; feed="$(feed_url)"
curl -fsSL "$feed/SHA256SUMS" \
| awk '$2 ~ /^punktfunk-.*-x86-64\.raw$/ { v=$2; sub(/^punktfunk-/,"",v); sub(/-x86-64\.raw$/,"",v); print v, $2, $1 }' \
| sort -V | tail -n1
awk '$2 ~ /^punktfunk-.*-x86-64\.raw$/ { v=$2; sub(/^punktfunk-/,"",v); sub(/-x86-64\.raw$/,"",v); print v, $2, $1 }' \
"$PF_TMP/SHA256SUMS" | sort -V | tail -n1
}
installed_version() {
@@ -156,6 +227,7 @@ cmd_install() {
if [ -n "$from_file" ]; then
do_install --from-file "$from_file"
else
fetch_manifest || exit 1
local l; l="$(latest)"
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
# shellcheck disable=SC2086
@@ -178,6 +250,7 @@ cmd_update() {
if [ "${1:-}" = --from-file ]; then do_install --from-file "${2:?}"; return; fi
local cur l ver
cur="$(installed_version)"
fetch_manifest || exit 1
l="$(latest)"
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
ver="${l%% *}"
@@ -197,7 +270,17 @@ cmd_status() {
echo "image: $([ -f "$IMG" ] && du -h "$IMG" | cut -f1 || echo '(not installed)')"
echo "merged: $(merged && echo yes || echo no)"
echo "installed: $(installed_version || true)"
echo "latest: $(latest 2>/dev/null | cut -d' ' -f1 || true)"
# Say WHY the feed is unreadable rather than printing a blank: unreachable and
# "signature does not verify" want very different reactions from whoever ran this.
if fetch_manifest 2>"$PF_TMP/status.err"; then
echo "latest: $(latest | cut -d' ' -f1)"
else
echo "latest: (unavailable)"
fi
# Unconditionally — fetch_manifest also warns on SUCCESS (ALLOW_UNSIGNED), and a status command
# that hides "this feed is not being verified" is worse than one that prints nothing at all.
[ -s "$PF_TMP/status.err" ] && sed 's/^/ /' "$PF_TMP/status.err" >&2
return 0
}
cmd_remove() {