diff --git a/.gitea/workflows/rpm.yml b/.gitea/workflows/rpm.yml index 0e76ef88..22575f04 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -199,16 +199,27 @@ jobs: dist/punktfunk-web-"${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 env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} + RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }} run: | case "$GROUP" in - *-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up - *) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release + *-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6; OTHER="f${{ matrix.fedver }}" ;; + *) FEED="f${{ matrix.fedver }}"; KEEP=0; OTHER="f${{ matrix.fedver }}-canary" ;; esac KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \ "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 # (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep diff --git a/SECURITY.md b/SECURITY.md index ba4ed0ec..81a74d42 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -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. +## 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 + `.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 We consider good-faith security research that follows this policy to be authorized, and we won't diff --git a/packaging/bazzite/README.md b/packaging/bazzite/README.md index c503861b..a7d90eca 100644 --- a/packaging/bazzite/README.md +++ b/packaging/bazzite/README.md @@ -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[-canary]/`), +SHA-256-verified against a signed manifest from the feed +`…/packages/unom/generic/punktfunk-sysext/f[-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 diff --git a/packaging/bazzite/publish-sysext-feed.sh b/packaging/bazzite/publish-sysext-feed.sh index f405b6ed..c39c64ea 100644 --- a/packaging/bazzite/publish-sysext-feed.sh +++ b/packaging/bazzite/publish-sysext-feed.sh @@ -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//) 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//) 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 +# TOKEN=… bash publish-sysext-feed.sh --seal # 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 }" -RAW="${2:?usage: publish-sysext-feed.sh }" -[ -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 }"; RAW="" +else + FEED="${1:?usage: publish-sysext-feed.sh }" + RAW="${2:?usage: publish-sysext-feed.sh }" + [ -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 diff --git a/packaging/bazzite/punktfunk-sysext.sh b/packaging/bazzite/punktfunk-sysext.sh index 44df0fbe..0e41a87f 100644 --- a/packaging/bazzite/punktfunk-sysext.sh +++ b/packaging/bazzite/punktfunk-sysext.sh @@ -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 , 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() { diff --git a/packaging/rpm/README.md b/packaging/rpm/README.md index 27d73d55..eed9bca2 100644 --- a/packaging/rpm/README.md +++ b/packaging/rpm/README.md @@ -88,6 +88,15 @@ curl --user ":" --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