`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>
140 lines
7.4 KiB
Bash
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)"
|