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
+13 -2
View File
@@ -199,16 +199,27 @@ jobs:
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \ dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm
# The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and
# punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved
# anything, sitting on the same registry as the images they describe.
- name: Publish the sysext feed - name: Publish the sysext feed
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }}
run: | run: |
case "$GROUP" in case "$GROUP" in
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up *-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6; OTHER="f${{ matrix.fedver }}" ;;
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release *) FEED="f${{ matrix.fedver }}"; KEEP=0; OTHER="f${{ matrix.fedver }}-canary" ;;
esac esac
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \ KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
# Re-seal this Fedora major's OTHER channel too. Stable feeds only publish on a tag, so
# without this a stable box would sit in front of an unsigned (hence refused) feed until
# the next release; canary pushes are frequent, so every live feed gets sealed within a
# day of this landing, and a key rotation propagates without rebuilding any image.
# Best-effort: a channel that has never published yet has no manifest to seal.
bash packaging/bazzite/publish-sysext-feed.sh --seal "$OTHER" \
|| echo "::warning::could not seal the $OTHER feed (no manifest yet?)"
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases # On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep # (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
+23
View File
@@ -56,6 +56,29 @@ https://docs.punktfunk.unom.io/docs/security):
If you're unsure whether something is in scope, report it anyway — we'd rather hear about it. If you're unsure whether something is in scope, report it anyway — we'd rather hear about it.
## Verifying what you downloaded
Every distribution path is authenticated. Nothing below needs an account or a network round trip to
us beyond the download itself.
- **Release-page downloads** (DMG, MSIX, setup.exe, APK, decky zip, .deb/.rpm) each ship a
`<file>.sha256` next to them. In your download directory:
`sha256sum -c punktfunk-1.2.3.dmg.sha256` (macOS: `shasum -a 256 -c …`).
- **RPMs** from the dnf repo are OpenPGP-signed with `packages@unom.io` (`AF245C506F4E4763`); the
repo file in [`packaging/rpm/README.md`](packaging/rpm/README.md) sets `gpgcheck=1`, so dnf
checks every package for you. `rpmkeys --checksig` on a downloaded RPM verifies it by hand.
- **The Bazzite sysext feed** carries a detached signature over its `SHA256SUMS`, from that same
key. `punktfunk-sysext` verifies it before installing and refuses a feed it cannot verify — the
public key is baked into the script rather than fetched from the feed.
- **Windows installers and MSIX packages** are Authenticode-signed; a release build that cannot
reach its code-signing certificate fails to build rather than falling back to a self-signed one.
Check with `Get-AuthenticodeSignature punktfunk-host-setup-1.2.3.exe`.
A checksum on its own only tells you the download wasn't corrupted in transit — it says nothing
about who produced the file, since anyone able to replace an artifact can replace its checksum.
Where that distinction matters (the update feeds, the package repos), the checksums are covered by
a signature. If a signature check fails, please don't work around it; report it.
## Safe harbor ## Safe harbor
We consider good-faith security research that follows this policy to be authorized, and we won't We consider good-faith security research that follows this policy to be authorized, and we won't
+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**, 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 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 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 (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: 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 - 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 **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 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 #!/usr/bin/env bash
# Publish a punktfunk sysext image into its feed on the Gitea generic package registry — # 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 # 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/<feed>/) holding versioned .raw files plus a SHA256SUMS manifest and its
# punktfunk-sysext(8) on the boxes reads SHA256SUMS to find + verify the newest image # detached OpenPGP signature SHA256SUMS.asc; punktfunk-sysext(8) on the boxes verifies that
# (the layout is also exactly what systemd-sysupdate's url-file source expects, so a # signature, then uses the manifest to find + check the newest image (the layout is also exactly
# .transfer feed can be added later without re-publishing anything). # 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> # 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) # <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 # 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 set -euo pipefail
FEED="${1:?usage: publish-sysext-feed.sh <feed> <image.raw>}" SEAL=0
RAW="${2:?usage: publish-sysext-feed.sh <feed> <image.raw>}" if [ "${1:-}" = --seal ]; then
[ -f "$RAW" ] || { echo "no such image: $RAW" >&2; exit 1; } 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}" REGISTRY="${REGISTRY:-git.unom.io}"
OWNER="${OWNER:-unom}" OWNER="${OWNER:-unom}"
KEEP="${KEEP:-0}" KEEP="${KEEP:-0}"
AUTH=(--user "${CURL_USER:-enricobuehler}:${TOKEN:?TOKEN (write:package PAT) required}") AUTH=(--user "${CURL_USER:-enricobuehler}:${TOKEN:?TOKEN (write:package PAT) required}")
BASE="https://$REGISTRY/api/packages/$OWNER/generic/punktfunk-sysext/$FEED" 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")" FNAME="$(basename "$RAW")"
SHA="$(sha256sum "$RAW" | cut -d' ' -f1)" SHA="$(sha256sum "$RAW" | cut -d' ' -f1)"
# Merge into the existing manifest: drop any prior line for this filename, append ours. # 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 curl -fsS "${AUTH[@]}" "$BASE/SHA256SUMS" 2>/dev/null | grep -v " $FNAME\$" > "$SUMS" || true
printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS" printf '%s %s\n' "$SHA" "$FNAME" >> "$SUMS"
@@ -38,13 +115,24 @@ if [ "$KEEP" -gt 0 ]; then
done done
fi fi
# Upload order keeps consumers consistent: image first, then the manifest referencing it, # Sign the finished manifest BEFORE anything is uploaded — a signing failure on a release must
# then prune deletions (already absent from the manifest). Delete-before-put makes workflow # abort while the live feed is still whole, not halfway through being replaced.
# re-runs idempotent (the registry 409s on duplicate filenames; first-publish 404s are fine). 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[@]}" -X DELETE "$BASE/$FNAME" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$RAW" "$BASE/$FNAME" 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[@]}" -X DELETE "$BASE/SHA256SUMS" || true
curl -fsS -o /dev/null "${AUTH[@]}" --upload-file "$SUMS" "$BASE/SHA256SUMS" 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 for f in "${PRUNE[@]:-}"; do
[ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; } [ -n "$f" ] && { echo "pruning $f"; curl -fsS -o /dev/null "${AUTH[@]}" -X DELETE "$BASE/$f" || true; }
done done
+92 -9
View File
@@ -16,10 +16,17 @@
# sudo punktfunk-sysext update | status | remove # sudo punktfunk-sysext update | status | remove
# #
# Feed: the Gitea generic package registry, one feed per Fedora major x channel # 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 — # (…/punktfunk-sysext/f43/, f43-canary, f44, …), each a SHA256SUMS + SHA256SUMS.asc + versioned
# published by .gitea/workflows/rpm.yml from the same RPMs the (legacy) layering path uses. # .raw files — published by .gitea/workflows/rpm.yml from the same RPMs the (legacy) layering path
# The image pins ID=fedora + VERSION_ID, so after a major OS rebase the old image is refused # 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. # (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 set -euo pipefail
REGISTRY="${PUNKTFUNK_SYSEXT_REGISTRY:-https://git.unom.io/api/packages/unom/generic/punktfunk-sysext}" 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" SIDECAR="$EXT_DIR/.punktfunk.version"
MARKER=/usr/lib/extension-release.d/extension-release.punktfunk MARKER=/usr/lib/extension-release.d/extension-release.punktfunk
ETC_SRC=/usr/share/punktfunk/etc 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() { usage() {
sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p' sed -n 's/^#\( \|$\)//p' "$0" | sed -n '1,20p'
@@ -47,12 +69,61 @@ feed_url() {
echo "$REGISTRY/f$(os_version_id)$suffix" 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() { latest() {
local feed; feed="$(feed_url)" awk '$2 ~ /^punktfunk-.*-x86-64\.raw$/ { v=$2; sub(/^punktfunk-/,"",v); sub(/-x86-64\.raw$/,"",v); print v, $2, $1 }' \
curl -fsSL "$feed/SHA256SUMS" \ "$PF_TMP/SHA256SUMS" | sort -V | tail -n1
| awk '$2 ~ /^punktfunk-.*-x86-64\.raw$/ { v=$2; sub(/^punktfunk-/,"",v); sub(/-x86-64\.raw$/,"",v); print v, $2, $1 }' \
| sort -V | tail -n1
} }
installed_version() { installed_version() {
@@ -156,6 +227,7 @@ cmd_install() {
if [ -n "$from_file" ]; then if [ -n "$from_file" ]; then
do_install --from-file "$from_file" do_install --from-file "$from_file"
else else
fetch_manifest || exit 1
local l; l="$(latest)" local l; l="$(latest)"
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; } [ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
# shellcheck disable=SC2086 # shellcheck disable=SC2086
@@ -178,6 +250,7 @@ cmd_update() {
if [ "${1:-}" = --from-file ]; then do_install --from-file "${2:?}"; return; fi if [ "${1:-}" = --from-file ]; then do_install --from-file "${2:?}"; return; fi
local cur l ver local cur l ver
cur="$(installed_version)" cur="$(installed_version)"
fetch_manifest || exit 1
l="$(latest)" l="$(latest)"
[ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; } [ -n "$l" ] || { echo "no image in the feed $(feed_url)" >&2; exit 1; }
ver="${l%% *}" ver="${l%% *}"
@@ -197,7 +270,17 @@ cmd_status() {
echo "image: $([ -f "$IMG" ] && du -h "$IMG" | cut -f1 || echo '(not installed)')" echo "image: $([ -f "$IMG" ] && du -h "$IMG" | cut -f1 || echo '(not installed)')"
echo "merged: $(merged && echo yes || echo no)" echo "merged: $(merged && echo yes || echo no)"
echo "installed: $(installed_version || true)" 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() { cmd_remove() {
+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 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. `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: After reboot, as the desktop user:
```sh ```sh