Files
punktfunk/packaging/bazzite/publish-sysext-feed.sh
T
enricobuehlerandClaude Opus 5 93902ff60e 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>
2026-07-29 12:12:21 +02:00

140 lines
7.4 KiB
Bash

#!/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 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
# --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
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.
curl -fsS "${AUTH[@]}" "$BASE/SHA256SUMS" 2>/dev/null | grep -v " $FNAME\$" > "$SUMS" || true
printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS"
# Prune: keep only the newest $KEEP images (by version sort) in manifest + registry.
PRUNE=()
if [ "$KEEP" -gt 0 ]; then
mapfile -t PRUNE < <(awk '{print $2}' "$SUMS" | sort -V | head -n -"$KEEP")
for f in "${PRUNE[@]:-}"; do
[ -n "$f" ] && sed -i "\| $f\$|d" "$SUMS"
done
fi
# 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
echo "published $FNAME -> $BASE ($(wc -l <"$SUMS") image(s) in the feed)"