From 1fd4c97139f68ba80a56265183121eb766912f9e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 15 Jun 2026 10:46:27 +0000 Subject: [PATCH] feat(rpm): wire per-package GPG signing (dormant until a key secret is set) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit's signing recommendation, scoped to RPM (apt's signed Release metadata already covers .debs; bootc cosign deferred). packaging/rpm/sign-rpms.sh GPG-signs dist/*.rpm and self-verifies (rpmkeys --checksig), run from rpm.yml between build + publish. Safe to ship: the step is a NO-OP (exit 0, unsigned as today) until RPM_GPG_PRIVATE_KEY is set as a CI secret — so it can't break current CI, and when enabled a bad macro fails loudly via the in-step checksig rather than shipping bad signatures. rpm/README gains the one-time enablement runbook (generate a dedicated passphrase-less key, add the secret, publish the public key, flip gpgcheck=1 only after a signed build lands) and notes step-ca is for TLS, not OpenPGP (it can't sign RPMs). Also fixes the rpm/README version staleness the doc review caught: rolling is 0.2.0-0.ciN (outranks the stray 0.1.1, no pin needed), host releases use host-v* not the client's v*. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/rpm.yml | 6 +++++ packaging/rpm/README.md | 42 ++++++++++++++++++++++++++++++++--- packaging/rpm/sign-rpms.sh | 45 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 3 deletions(-) create mode 100755 packaging/rpm/sign-rpms.sh diff --git a/.gitea/workflows/rpm.yml b/.gitea/workflows/rpm.yml index f1773a8..8768f18 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -86,6 +86,12 @@ jobs: # globs it in; the host RPM Recommends it). Needs bun (ensured in Prep). run: PF_VERSION="$PF_VERSION" PF_RELEASE="$PF_RELEASE" PF_WITH_WEB=1 bash packaging/rpm/build-rpm.sh + - name: Sign RPMs (dormant until RPM_GPG_PRIVATE_KEY is set — see packaging/rpm/README.md) + env: + RPM_GPG_PRIVATE_KEY: ${{ secrets.RPM_GPG_PRIVATE_KEY }} + RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }} + run: bash packaging/rpm/sign-rpms.sh + - name: Publish to the Gitea RPM registry env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} diff --git a/packaging/rpm/README.md b/packaging/rpm/README.md index 7a4e45c..e157003 100644 --- a/packaging/rpm/README.md +++ b/packaging/rpm/README.md @@ -3,7 +3,9 @@ `punktfunk-host` is published as an RPM to **Gitea's RPM package registry** in the public `unom` org (group `bazzite`), so Bazzite / Fedora Atomic hosts layer and update it with `rpm-ostree`. CI (`.gitea/workflows/rpm.yml`) builds and publishes on every push to `main` (a rolling -`0.0.1-0.ciN.` build) and on `v*` tags (a clean `X.Y.Z-1`). The RPM is built in the +`0.2.0-0.ciN.` build, which outranks the stray `0.1.1` so `rpm-ostree upgrade` always gets the +latest — no version pin needed) and on **host-scoped** `host-v*` tags (a clean `X.Y.Z-1`; the Apple +client's `v*` tags deliberately do **not** publish a host RPM). The RPM is built in the Fedora 43 image (`ci/fedora-rpm.Dockerfile`) so its auto-generated library Requires (`libavcodec.so.NN`, …) match Bazzite's sonames; the NVIDIA driver lib (`libcuda.so.1`) is excluded — NVENC/EGL come from whatever NVIDIA stack the host runs (a weak Recommends). @@ -37,8 +39,42 @@ systemctl reboot ``` > If `rpm-ostree` can't complete the metadata GPG check non-interactively, set `repo_gpgcheck=0` -> (TLS-only trust to the self-hosted registry). Proper per-package signing (`gpgcheck=1`) would -> need a CI signing key + `rpm --addsign` — future hardening, not wired up. +> (TLS-only trust to the self-hosted registry). + +## Enabling per-package signing (`gpgcheck=1`) + +CI is wired to GPG-sign each RPM (`packaging/rpm/sign-rpms.sh`, run from `rpm.yml`), but it's +**dormant** until you provide a signing key — until then packages publish unsigned and the repo +above uses `gpgcheck=0`. This is a self-hosted registry served over HTTPS with GPG-signed metadata +(`repo_gpgcheck=1`), so per-package signing is hardening, not a correctness fix. (Note: this is a +GPG/OpenPGP key — a `step-ca`/X.509 cert can't sign RPMs; step-ca is for the registry/console TLS.) + +One-time setup: + +```sh +# 1. Generate a DEDICATED, passphrase-less signing key (separate from the Gitea registry key). +gpg --batch --gen-key < paste into the CI secret below +gpg --armor --export packages@unom.io > RPM-GPG-KEY-punktfunk # the PUBLIC key + +# 2. In the repo's Gitea Actions secrets, add RPM_GPG_PRIVATE_KEY = the armored PRIVATE key +# (and RPM_GPG_PASSPHRASE only if the key has one). The next CI run signs + self-verifies. + +# 3. Publish RPM-GPG-KEY-punktfunk where clients can fetch it, then on each host import it and +# flip the repo to gpgcheck=1: +sudo rpm --import https://git.unom.io/.../RPM-GPG-KEY-punktfunk +sudo sed -i 's/^gpgcheck=0/gpgcheck=1/' /etc/yum.repos.d/punktfunk.repo +``` + +Do **not** flip `gpgcheck=1` before a signed build has published, or installs will fail. After reboot, as the desktop user: diff --git a/packaging/rpm/sign-rpms.sh b/packaging/rpm/sign-rpms.sh new file mode 100755 index 0000000..2d2d5ff --- /dev/null +++ b/packaging/rpm/sign-rpms.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Detached-GPG-sign the built dist/*.rpm so the Gitea RPM registry can be served with gpgcheck=1. +# +# DORMANT by default: if RPM_GPG_PRIVATE_KEY is unset this exits 0 and leaves the RPMs unsigned — +# exactly today's behaviour — so it is SAFE to ship before a key exists. The signing only activates +# once you add the key as a CI secret (see packaging/rpm/README.md "Enabling per-package signing"). +# +# Recommended: a DEDICATED, PASSPHRASE-LESS signing key (simplest in CI), distinct from the Gitea +# instance's repo-metadata key. If your key has a passphrase, set RPM_GPG_PASSPHRASE too. +# +# Usage (in rpm.yml, after build-rpm.sh): RPM_GPG_PRIVATE_KEY=... [RPM_GPG_PASSPHRASE=...] bash packaging/rpm/sign-rpms.sh +set -euo pipefail + +if [ -z "${RPM_GPG_PRIVATE_KEY:-}" ]; then + echo "RPM_GPG_PRIVATE_KEY unset — leaving dist/*.rpm UNSIGNED (registry stays gpgcheck=0)." + exit 0 +fi + +command -v rpmsign >/dev/null 2>&1 || dnf -y install rpm-sign >/dev/null + +GNUPGHOME="$(mktemp -d)"; export GNUPGHOME; chmod 700 "$GNUPGHOME" +trap 'rm -rf "$GNUPGHOME"' EXIT +echo "allow-loopback-pinentry" > "$GNUPGHOME/gpg-agent.conf" + +printf '%s' "$RPM_GPG_PRIVATE_KEY" | gpg --batch --import +KEYID="$(gpg --list-secret-keys --with-colons | awk -F: '/^sec:/{print $5; exit}')" +[ -n "$KEYID" ] || { echo "no secret key imported from RPM_GPG_PRIVATE_KEY" >&2; exit 1; } + +# rpm v4 detached-signing macro. Force loopback pinentry (no TTY in CI); feed the passphrase, if +# any, on stdin via --passphrase-fd 0. +SIGN_CMD="%{__gpg} gpg --batch --no-verbose --no-armor --pinentry-mode loopback" +[ -n "${RPM_GPG_PASSPHRASE:-}" ] && SIGN_CMD="$SIGN_CMD --passphrase-fd 0" +SIGN_CMD="$SIGN_CMD -u %{_gpg_name} --digest-algo sha256 -sbo %{__signature_filename} %{__plaintext_filename}" + +for rpm in dist/*.rpm; do + printf '%s' "${RPM_GPG_PASSPHRASE:-}" | rpmsign \ + --define "_gpg_name $KEYID" \ + --define "__gpg_sign_cmd $SIGN_CMD" \ + --addsign "$rpm" +done + +# Verify locally so a bad signature fails the build before publishing. +rpm --import <(gpg --export --armor "$KEYID") +rpmkeys --checksig dist/*.rpm +echo "signed + verified $(find dist -name '*.rpm' | wc -l) RPM(s) with key $KEYID"