From d8fa6df072d7f265140bcca91bdf6112f151f146 Mon Sep 17 00:00:00 2001 From: xavierk Date: Thu, 3 Sep 2026 14:14:55 +0530 Subject: [PATCH] signing: rpm payload signing, key publication, consumer repo setup for #51 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the signing and consumer-repo trust infrastructure: - Makefile: add generate-test-key, sign-rpm, checksums, clearsign targets; make release now automates the full build→sign→checksum→clearsign flow - Key ceremony: document the import→sign→delete lifecycle, key rotation outline, and private-key-in-password-manager policy - Public key: update placeholder with raw URL, algorithm, and ceremony ref - Consumer docs: README now covers apt signed-by keyring flow, dnf repo file setup, signature verification commands, and migration runbook link - Release spec: updated to reference ceremony doc and rpmsign workflow - Tests: 36 structural signing tests (nfpm config, Makefile targets, repo file, key publication, ceremony doc, consumer docs, spec refs) plus throwaway-key RPM signature and clearsign mechanics; no network or real key required Co-authored-by: CommandCodeBot --- Makefile | 96 ++++-- README.md | 137 ++++++-- docs/install/signing-key-ceremony.md | 134 ++++++++ docs/spec/release-packaging.md | 6 +- packaging/keys/fenris-packaging.asc | 5 +- tests/test_signing.py | 479 +++++++++++++++++++++++++++ 6 files changed, 809 insertions(+), 48 deletions(-) create mode 100644 docs/install/signing-key-ceremony.md create mode 100644 tests/test_signing.py diff --git a/Makefile b/Makefile index a4bec9b..ba12774 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ MANIFEST := $(DATA_DIR)/manifest.txt # Legacy history path (IN-4) LEGACY_HISTORY := ./data/history.jsonl -.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package release clean +.PHONY: help install upgrade uninstall purge update-deps test lint check-python check-smartctl import-legacy stage package-deb package-rpm package generate-test-key sign-rpm checksums clearsign release clean help: @echo "Fenris NVMe endurance monitor" @@ -34,7 +34,11 @@ help: @echo " package - Build deb + rpm packages" @echo " package-deb - Build deb package only" @echo " package-rpm - Build rpm package only" - @echo " release - Full release (build, sign, attach)" + @echo " generate-test-key - Create throwaway GPG key for CI/testing" + @echo " sign-rpm - Sign RPM payload with packaging key" + @echo " checksums - Generate SHA256SUMS manifest" + @echo " clearsign - Clearsign SHA256SUMS with packaging key" + @echo " release - Full release (build, sign, checksum, print upload steps)" @echo " clean - Remove build artifacts" # ─── Pre-install gates ────────────────────────────────────────────────────── @@ -223,11 +227,14 @@ lint: update-deps: $(PYTHON) -m pip compile pyproject.toml -o requirements.txt -# ─── Packaging (spec §3, §5) ──────────────────────────────────────────────── +# ─── Packaging (spec §3, §4, §5) ──────────────────────────────────────────── # Version is sourced from pyproject.toml for both formats FENRIS_VERSION := $(shell sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml) +# GPG signing — packaging key UID (spec §4) +PACKAGING_KEY ?= packaging@bongbetic.com + stage: dist/fenris-*.whl @echo "=== Staging packaging tree (v$(FENRIS_VERSION)) ===" bash packaging/stage.sh "$(FENRIS_VERSION)" @@ -245,26 +252,71 @@ package-rpm: stage package: package-deb package-rpm @echo "=== Both packages built in dist/ ===" -release: package - @echo "=== Release v$(FENRIS_VERSION) ===" - @echo "Artifacts:" - @ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm 2>/dev/null +# ─── GPG key management ───────────────────────────────────────────────────── + +generate-test-key: + @echo "=== Generating throwaway test GPG key ===" + @echo "This key is for CI/testing only — never use for real releases." + printf '%%no-protection\nKey-Type: RSA\nKey-Length: 3072\nName-Real: Fenris Packaging (TESTING ONLY)\nName-Email: packaging-test@bongbetic.com\nExpire-Date: 0\n%%commit\n' | \ + gpg --batch --gen-key + @echo "=== Test key created. Fingerprint: ===" + @gpg --fingerprint packaging-test@bongbetic.com + +# ─── Signing ──────────────────────────────────────────────────────────────── + +sign-rpm: package-rpm + @echo "=== Signing RPM payload ===" + @rpm --import packaging/keys/fenris-packaging.asc 2>/dev/null || true + rpmsign --addsign --define "_gpg_name $(PACKAGING_KEY)" \ + dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm + @echo "=== RPM signed ===" + @rpm -Kv dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm + +checksums: package + @echo "=== Generating SHA256SUMS ===" + cd dist && sha256sum fenris_$(FENRIS_VERSION)_amd64.deb \ + fenris-$(FENRIS_VERSION)-1.x86_64.rpm > SHA256SUMS + @echo "=== SHA256SUMS written ===" + @cat dist/SHA256SUMS + +clearsign: checksums + @echo "=== Clearsigning SHA256SUMS ===" + gpg --batch --yes --clearsign --local-user $(PACKAGING_KEY) \ + dist/SHA256SUMS + @echo "=== SHA256SUMS.asc written ===" + +# ─── Release (spec §5) ────────────────────────────────────────────────────── + +release: package sign-rpm clearsign @echo "" - @echo "Manual steps (spec §5):" - @echo " 1. Import packaging key: gpg --import " - @echo " 2. Sign RPM payload: rpmsign --addsign dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm" - @echo " 3. Generate checksums: cd dist && sha256sum fenris_$(FENRIS_VERSION)_amd64.deb fenris-$(FENRIS_VERSION)-1.x86_64.rpm > SHA256SUMS" - @echo " 4. Clearsign manifest: gpg --clearsign dist/SHA256SUMS" - @echo " 5. Upload to registry:" - @echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" - @echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/main/upload'" - @echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" - @echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/jammy/main/upload'" - @echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" - @echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/noble/main/upload'" - @echo " curl -X PUT -u user:token -T dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \\" - @echo " 'https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload'" - @echo " 6. Create Gitea release with notes and attach .deb, .rpm, SHA256SUMS.asc" + @echo "=== Release v$(FENRIS_VERSION) ===" + @echo "" + @echo "Artifacts:" + @ls -la dist/fenris_$(FENRIS_VERSION)_amd64.deb \ + dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \ + dist/SHA256SUMS.asc 2>/dev/null + @echo "" + @echo "Verify signing (manual):" + @echo " rpm -Kv dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm" + @echo " gpg --verify dist/SHA256SUMS.asc dist/SHA256SUMS" + @echo "" + @echo "Upload to registry:" + @echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" + @echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/bookworm/main/upload'" + @echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" + @echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/jammy/main/upload'" + @echo " curl -X PUT -u user:token -T dist/fenris_$(FENRIS_VERSION)_amd64.deb \\" + @echo " 'https://git.bongbetic.com/api/packages/xavierk/debian/pool/noble/main/upload'" + @echo " curl -X PUT -u user:token -T dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm \\" + @echo " 'https://git.bongbetic.com/api/packages/xavierk/rpm/fenris/upload'" + @echo "" + @echo "Create Gitea release with notes and attach:" + @echo " dist/fenris_$(FENRIS_VERSION)_amd64.deb" + @echo " dist/fenris-$(FENRIS_VERSION)-1.x86_64.rpm" + @echo " dist/SHA256SUMS.asc" + @echo "" + @echo "Key ceremony: delete the private key after upload." + @echo " See docs/install/signing-key-ceremony.md" clean: @echo "=== Cleaning build artifacts ===" diff --git a/README.md b/README.md index a444334..555ad6e 100644 --- a/README.md +++ b/README.md @@ -8,34 +8,109 @@ Fenris is a persistent TUI monitor backed by a short-lived privileged collector ## Requirements -- **Python ≥ 3.9** (verified at install time) +- **Python ≥ 3.9** (verified at install time; ≥ 3.10 for packages) - **smartmontools** (`smartctl` — verified at install time) - **systemd** with a polkit agent (the collector runs as root oneshot; elevation is exclusively polkit) No other OS packages or Python dependencies beyond [Textual](https://textual.textualize.io/) (pinned in the lockfile). -## Install +## Install from package (recommended) -```bash -sudo make install +### Debian / Ubuntu (apt) + +The Gitea instance Debian registry signs metadata with its own key. Verify the +instance key fingerprint (TOFU hardening): + +```text +Fingerprint: ``` -What it does: -1. Builds a wheel from the checkout and installs it — with pinned dependencies — into the dedicated venv at `/opt/fenris`. -2. Places the `fenris` wrapper in `/usr/local/bin`, helpers in `/usr/libexec/fenris`, systemd units in `/etc/systemd/system`, and the polkit policy in `/usr/share/polkit-1/actions/`. -3. Creates `/var/lib/fenris` (root-written, group-readable) — the observation store is created lazily by the first collection run. -4. Records every placed file in a manifest consumed by upgrade and uninstall. -5. Detects `./data/history.jsonl` beside the source checkout and runs the idempotent legacy import if present. +Add the instance key and repository: -**A fresh install is fully dormant.** Units are present but disabled; nothing runs. The only opt-in is the sanctioned toggle: +```bash +sudo mkdir -p /etc/apt/keyrings +sudo curl -fsSL -o /etc/apt/keyrings/gitea-xavierk.asc \ + https://git.bongbetic.com/api/packages/xavierk/debian/repository.key + +echo "deb [signed-by=/etc/apt/keyrings/gitea-xavierk.asc] \ + https://git.bongbetic.com/api/packages/xavierk/debian bookworm main" \ + | sudo tee /etc/apt/sources.list.d/fenris.list + +sudo apt update && sudo apt install fenris +``` + +Replace `bookworm` with your distribution codename (`bookworm`, `jammy`, or +`noble`). + +### Fedora (dnf) + +Use the Fenris-owned repo file (not Gitea's auto-generated one): + +```bash +sudo dnf config-manager --add-repo \ + https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/fenris.repo + +sudo dnf install fenris +``` + +The repo file sets `gpgcheck=1` against the Fenris packaging key (downloaded +from the raw URL in `gpgkey`) and `repo_gpgcheck=0` (metadata check left to +TLS). + +### Package signature verification + +The RPM payload is signed with the Fenris packaging key (RSA 3072). +Verification happens automatically via dnf's `gpgcheck=1`. For manual +verification of downloaded assets: + +```bash +rpm -Kv fenris-*.x86_64.rpm # RPM payload signature +gpg --verify SHA256SUMS.asc SHA256SUMS # Clearsigned checksum manifest +sha256sum -c SHA256SUMS # Checksum match +``` + +The packaging public key is published in-repo — no keyservers. See +`packaging/keys/fenris-packaging.asc` and +`docs/install/signing-key-ceremony.md` for key lifecycle details. + +### Dormant install + +A fresh package install is fully dormant. Units are present but disabled; +nothing runs. The only opt-in is the sanctioned toggle: ```bash fenris monitor resume # enable timer + open first monitoring period fenris monitor pause # close the period, disable timer ``` +## Development install (make install) + +For contributors building from source: + +```bash +sudo make install +``` + +This builds a wheel, installs it into `/opt/fenris` with pinned dependencies, +and places helpers, units, and the polkit policy. Units are dormant by default. + +```bash +sudo make upgrade # re-sync wheel, units, schema +make uninstall # removes artifacts, preserves config and store +make purge # also removes /etc/fenris and /var/lib/fenris +``` + ## Upgrade +### Package upgrade + +```bash +sudo apt update && sudo apt upgrade fenris # Debian/Ubuntu +sudo dnf upgrade fenris # Fedora +``` + +### Development upgrade + ```bash sudo make upgrade ``` @@ -49,8 +124,26 @@ What it does: Rollback: reinstall the previous version and restore `observations.db.bak`. +## Migration from make install + +If Fenris was previously installed with `sudo make uninstall` first, then +installed from the package, existing config, store, and group survive by path +continuity. Over-installing the package over a `make install` is +**forbidden** — stale units shadow vendor placement. See +[docs/install/migrate-from-makeinstall.md](docs/install/migrate-from-makeinstall.md). + ## Uninstall and purge +### Package removal + +```bash +sudo apt remove fenris # preserves config and store +sudo apt purge fenris # also removes config and store +sudo dnf remove fenris # preserves config and store +``` + +### Development removal + ```bash make uninstall # removes artifacts, preserves config and observation history make purge # also removes /etc/fenris and /var/lib/fenris @@ -110,17 +203,17 @@ Use a stable `/dev/disk/by-id/` path. Raw `/dev/nvmeX` paths are warned against. ## Where's my stuff? -| Artifact | Location | -|---|---| -| Wrapper | `/usr/local/bin/fenris` | -| Helpers | `/usr/libexec/fenris/fenris-collect`, `fenris-monitor` | -| Units | `/etc/systemd/system/fenris-collect.{timer,service}` | -| Polkit policy | `/usr/share/polkit-1/actions/com.bongbetic.fenris.monitor.policy` | -| Configuration | `/etc/fenris/fenris.conf` | -| Observation store | `/var/lib/fenris/observations.db` | -| Venv | `/opt/fenris` | -| Manifest | `/var/lib/fenris/manifest.txt` | -| Legacy history | `./data/history.jsonl` (auto-imported on install if present) | +| Artifact | Package install | make install | +|---|---|---| +| Wrapper | `/usr/bin/fenris` | `/usr/local/bin/fenris` | +| Helpers | `/usr/libexec/fenris/` | `/usr/libexec/fenris/` | +| Units | `/usr/lib/systemd/system/` (vendor) | `/etc/systemd/system/` | +| Polkit policy | `/usr/share/polkit-1/actions/` | `/usr/share/polkit-1/actions/` | +| sysusers/tmpfiles | `/usr/lib/{sysusers,tmpfiles}.d/fenris.conf` | managed by Makefile | +| Configuration | `/etc/fenris/fenris.conf` | `/etc/fenris/fenris.conf` | +| Observation store | `/var/lib/fenris/observations.db` | `/var/lib/fenris/observations.db` | +| Venv | `/opt/fenris` | `/opt/fenris` | +| Legacy history | — | `./data/history.jsonl` (auto-imported) | --- diff --git a/docs/install/signing-key-ceremony.md b/docs/install/signing-key-ceremony.md new file mode 100644 index 0000000..64da1e7 --- /dev/null +++ b/docs/install/signing-key-ceremony.md @@ -0,0 +1,134 @@ +# Signing key ceremony + +The Fenris packaging key signs RPM payloads and clearsigns SHA256SUMS manifests. +This document describes the key's lifecycle: creation, per-release use, rotation, +and destruction. + +## Key specification + +| Property | Value | +|---|---| +| Algorithm | RSA 3072 | +| UID | `Fenris Packaging ` | +| Expiry | 2 years from creation | +| Hierarchy | Single key — no master/subkey split (single maintainer, manual builds) | +| Private key storage | Password manager only | +| Public key storage | `packaging/keys/fenris-packaging.asc` in-repo, release notes, docs | +| Keyservers | Never — TOFU-over-TLS via raw URL | + +## First release: key creation + +```bash +# Generate the dedicated RSA-3072 packaging key +gpg --batch --gen-key < packaging/keys/fenris-packaging.asc + +# Print the fingerprint for docs and release notes +gpg --fingerprint packaging@bongbetic.com +``` + +Save the **private key** to the password manager immediately: + +```bash +gpg --armor --export-secret-keys packaging@bongbetic.com +``` + +Then **delete the private key from the local keyring** — it must never persist +on any build host: + +```bash +gpg --delete-secret-keys packaging@bongbetic.com +gpg --delete-keys packaging@bongbetic.com +``` + +The committed `fenris-packaging.asc` must contain the real public key (replace +the placeholder comments). + +## Per-release signing flow + +Each release performs: **import → sign → delete**. The private key is never +stored on disk longer than the release takes. + +### Step 1: Import the private key + +Retrieve the private key from the password manager and import it: + +```bash +gpg --import /tmp/packaging-key-private.asc +rm /f /tmp/packaging-key-private.asc # Shred if possible +``` + +### Step 2: Build and sign packages + +The Makefile target `make release` handles signing automatically when the +key is in the keyring: + +```bash +make release # builds, signs RPM, clearsigns SHA256SUMS, prints upload steps +``` + +Under the hood: + +1. `nfpm pkg -p rpm` signs the RPM payload via `rpm.signature.key_file` and + `rpm.signature.key_id` in `packaging/nfpm.yaml`. +2. `sha256sum` generates the checksum manifest. +3. `gpg --clearsign` produces `SHA256SUMS.asc` with the packaging key. + +### Step 3: Delete the private key + +Immediately after signing: + +```bash +gpg --delete-secret-keys packaging@bongbetic.com +gpg --delete-keys packaging@bongbetic.com +``` + +Verify the key is gone: + +```bash +gpg --list-keys packaging@bongbetic.com +# Should produce: gpg: keyblock resource ...: No such file or directory +``` + +The entire import → sign → delete cycle should take minutes. The private key +must never be left in any keyring between releases. + +## Key rotation (outline) + +When the key approaches expiry, or if it is compromised: + +1. **Generate a new key** using the same procedure as first release. +2. **Publish the new public key** alongside the old one in-repo: + ```text + packaging/keys/fenris-packaging.asc # new key (primary) + packaging/keys/fenris-packaging-previous.asc # old key (one cycle) + ``` +3. **Sign the next RPM** with the new key. +4. **Update `fenris.repo`** to list both `gpgkey` URLs (dnf accepts multiple): + ```ini + gpgkey=https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc + https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging-previous.asc + ``` +5. **Drop the old key** from the repo after one release cycle. Delete + `fenris-packaging-previous.asc` and revert `gpgkey` to the single URL. + +## Verification + +Consumers verify the RPM payload signature via dnf (gpgcheck=1 in +`fenris.repo` points at the published public key). The SHA256SUMS manifest +verification is manual for downloaded assets: + +```bash +gpg --verify SHA256SUMS.asc SHA256SUMS +sha256sum -c SHA256SUMS +``` diff --git a/docs/spec/release-packaging.md b/docs/spec/release-packaging.md index 0ba3f3b..9389fbf 100644 --- a/docs/spec/release-packaging.md +++ b/docs/spec/release-packaging.md @@ -40,10 +40,10 @@ ## 4. Signing and key policy -- **RPM payload: signed.** rpmsign with the dedicated packaging key, wired through the nfpm config. This is required, not optional: it is the only working dnf-native verification path. +- **RPM payload: signed.** rpmsign with the dedicated packaging key, invoked by `make sign-rpm` after the package is built. This is required, not optional: it is the only working dnf-native verification path. - **deb: unsigned.** apt never verifies payload signatures; trust = instance-signed `InRelease` (signed-by keyring) + TLS + Acquire-By-Hash. Manual-download integrity is covered by SHA256SUMS. - **SHA256SUMS: clearsigned** with the packaging key — the trust anchor for manually downloaded release assets, independent of TLS. -- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging `, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host. +- **Packaging key:** single dedicated key, RSA 3072, UID `Fenris Packaging `, 2-year expiry, no master/subkey hierarchy (single maintainer, manual builds). Private key lives in the password manager only; each release does import → sign → delete — nothing permanent on any build host. The full ceremony is documented in `docs/install/signing-key-ceremony.md`. - **Public key publication:** in-repo `packaging/keys/fenris-packaging.asc` (raw URL doubles as the `.repo` gpgkey target), release notes, docs page. No keyservers — TOFU-over-TLS. - **Rotation (outline):** new key published alongside old; rpm signed with the new key; `fenris.repo` gpgkey lists both URLs (dnf accepts multiple); old key dropped after one release cycle. Procedure details stay in map fog. @@ -52,7 +52,7 @@ - **A Release is:** a version tag, its packages in the channel, a Gitea release entry with notes, and a clearsigned SHA256SUMS — all together. **Bare tags are forbidden** (tag without packages + release entry is not a Release). - **Cadence: on-demand.** Tag when user-visible changes or fixes accumulate; no calendar, no empty releases, no frequency SLA, no RC ceremony — fixes ship as a revision bump of the current version. - **Versioning: plain semver.** Major = breaking CLI/config/unit change; store schema changes ride the natural bump (the forward-only refusal handles old-reader/new-store). -- **Promotion flow:** tag → `make release` (manual: `make package` + rpmsign + registry PUTs + attach `.deb`, `.rpm`, `SHA256SUMS` to the release entry). +- **Promotion flow:** tag → `make release` (automated: `make package` → RPM signing via nfpm → SHA256SUMS generation → clearsign → prints registry PUTs + Gitea release steps). The ceremony is documented in `docs/install/signing-key-ceremony.md`. - **Rollback:** installing an older package over a newer store is **unsupported** — the store's forward-only version refusal fails it by design. Documented rollback = restore the observation-store snapshot, then install the old Release. No automatic downgrade machinery exists or will be built. - **CI:** no runners are registered on the instance today ([Actions runner research](https://git.bongbetic.com/xavierk/Fenris/issues/37)), so the manual flow above is primary. A dormant `.gitea/workflows/release.yml` (`on: push: tags: ['v*']`, single job, host-mode runner) is committed alongside; if it fires, it replicates `make release`. Cheapest future upgrade: one `act_runner` static binary in host-label mode on the existing Gitea host. diff --git a/packaging/keys/fenris-packaging.asc b/packaging/keys/fenris-packaging.asc index f9b5af1..5f17729 100644 --- a/packaging/keys/fenris-packaging.asc +++ b/packaging/keys/fenris-packaging.asc @@ -12,4 +12,7 @@ # Expiry: 2 years from creation # # This file will be replaced with the real public key at the time of the -# first Release. Its raw URL doubles as the dnf gpgkey target. +# first Release. Its raw URL doubles as the dnf gpgkey target: +# https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/packaging/keys/fenris-packaging.asc +# +# See docs/install/signing-key-ceremony.md for the full key lifecycle. diff --git a/tests/test_signing.py b/tests/test_signing.py new file mode 100644 index 0000000..f7fce95 --- /dev/null +++ b/tests/test_signing.py @@ -0,0 +1,479 @@ +"""Signing and consumer-repo structural tests (issue #51). + +Verifies that the signing infrastructure, consumer setup docs, and key +publication are correctly wired — without requiring a real GPG key, +network access, or Docker. + +All assertions are structural: config keys exist, URLs match, docs +are present, and the Makefile exposes the right targets. A throwaway +test key exercise is included for rpm signature verification mechanics. +""" +import subprocess +import tempfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _read(path: str | Path) -> str: + return (REPO_ROOT / path).read_text() + + +def _gpg_available() -> bool: + try: + r = subprocess.run( + ["gpg", "--version"], capture_output=True, timeout=5, + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +def _rpmsign_available() -> bool: + try: + r = subprocess.run( + ["rpmsign", "--version"], capture_output=True, timeout=5, + ) + return r.returncode == 0 + except (FileNotFoundError, subprocess.TimeoutExpired): + return False + + +# --------------------------------------------------------------------------- +# Tests — nfpm.yaml signing configuration +# --------------------------------------------------------------------------- + +class TestNfpmSigningConfig: + """Verify that nfpm.yaml is correctly configured for RPM builds. + + Note: RPM signing is done via rpmsign post-build (make sign-rpm), + not through nfpm's built-in signing. This keeps the build unsigned + and the signing step explicit and key-controlled. + """ + + def test_rpm_overrides_exist(self): + """nfpm.yaml must have overrides.rpm for per-format deltas.""" + content = _read("packaging/nfpm.yaml") + assert "overrides:" in content, "overrides section missing from nfpm.yaml" + assert "rpm:" in content, "rpm overrides missing from nfpm.yaml" + + def test_rpm_scripts_configured(self): + """RPM must use the dedicated scriptlets, not deb scripts.""" + content = _read("packaging/nfpm.yaml") + assert "packaging/rpm/post.sh" in content, \ + "RPM postinstall must use rpm/post.sh" + assert "packaging/rpm/preun.sh" in content, \ + "RPM preremove must use rpm/preun.sh" + assert "packaging/rpm/postun.sh" in content, \ + "RPM postremove must use rpm/postun.sh" + + def test_rpm_depends_use_correct_syntax(self): + """RPM dependencies must use rpm-style version syntax.""" + content = _read("packaging/nfpm.yaml") + assert "python3 >= 3.10" in content, \ + "RPM depends must use rpm-style version constraint" + + +# --------------------------------------------------------------------------- +# Tests — Makefile signing targets +# --------------------------------------------------------------------------- + +class TestMakefileSigningTargets: + """Verify that the Makefile exposes signing-related targets.""" + + def _makefile_content(self) -> str: + return _read("Makefile") + + def test_generate_test_key_target(self): + content = self._makefile_content() + assert "generate-test-key:" in content, \ + "Makefile must have a generate-test-key target" + assert "packaging-test@bongbetic.com" in content or \ + "packaging@bongbetic.com" in content, \ + "generate-test-key must reference the packaging key UID" + + def test_sign_rpm_target(self): + content = self._makefile_content() + assert "sign-rpm:" in content, \ + "Makefile must have a sign-rpm target" + assert "rpmsign" in content, \ + "sign-rpm target must use rpmsign" + + def test_checksums_target(self): + content = self._makefile_content() + assert "checksums:" in content, \ + "Makefile must have a checksums target" + assert "sha256sum" in content, \ + "checksums target must use sha256sum" + + def test_clearsign_target(self): + content = self._makefile_content() + assert "clearsign:" in content, \ + "Makefile must have a clearsign target" + assert "--clearsign" in content, \ + "clearsign target must use gpg --clearsign" + + def test_release_depends_on_signing(self): + content = self._makefile_content() + for line in content.splitlines(): + if line.startswith("release:"): + deps = line.split(":", 1)[1].strip() + assert "sign-rpm" in deps, \ + "release target must depend on sign-rpm" + assert "clearsign" in deps, \ + "release target must depend on clearsign" + break + else: + pytest.fail("release target not found in Makefile") + + def test_packaging_key_uid_defined(self): + content = self._makefile_content() + assert "PACKAGING_KEY" in content, \ + "Makefile must define PACKAGING_KEY variable" + + def test_release_mentions_key_ceremony(self): + content = self._makefile_content() + assert "signing-key-ceremony.md" in content, \ + "release target must reference the key ceremony doc" + + +# --------------------------------------------------------------------------- +# Tests — fenris.repo configuration +# --------------------------------------------------------------------------- + +class TestFenrisRepo: + """Verify the dnf repo file is correctly configured for Fenris.""" + + def _repo_content(self) -> str: + return _read("packaging/fenris.repo") + + def test_gpgcheck_enabled(self): + content = self._repo_content() + assert "gpgcheck=1" in content, \ + "fenris.repo must set gpgcheck=1 for payload verification" + + def test_repo_gpgcheck_disabled(self): + content = self._repo_content() + assert "repo_gpgcheck=0" in content, \ + "fenris.repo must set repo_gpgcheck=0 (metadata check via TLS)" + + def test_gpgkey_points_to_packaging_key(self): + content = self._repo_content() + assert "gpgkey=" in content, \ + "fenris.repo must have a gpgkey directive" + assert "fenris-packaging.asc" in content, \ + "gpgkey must point at the packaging key" + assert "raw/branch/main" in content, \ + "gpgkey must use raw URL for the public key" + + def test_baseurl_is_fenris_rpm_group(self): + content = self._repo_content() + assert "rpm/fenris" in content, \ + "baseurl must point at the fenris RPM group" + + +# --------------------------------------------------------------------------- +# Tests — public key publication +# --------------------------------------------------------------------------- + +class TestKeyPublication: + """Verify the public key is published in-repo with correct metadata.""" + + def test_key_file_exists(self): + key_path = REPO_ROOT / "packaging" / "keys" / "fenris-packaging.asc" + assert key_path.exists(), \ + "packaging/keys/fenris-packaging.asc must exist" + + def test_key_file_has_raw_url(self): + content = _read("packaging/keys/fenris-packaging.asc") + raw_url = ( + "https://git.bongbetic.com/xavierk/Fenris/raw/branch/main/" + "packaging/keys/fenris-packaging.asc" + ) + assert raw_url in content, \ + "Key file must contain its own raw URL as documentation" + + def test_key_file_documents_algorithm(self): + content = _read("packaging/keys/fenris-packaging.asc") + assert "RSA 3072" in content or "rsa3072" in content.lower(), \ + "Key file must document the algorithm as RSA 3072" + + def test_key_file_documents_uid(self): + content = _read("packaging/keys/fenris-packaging.asc") + assert "Fenris Packaging" in content, \ + "Key file must document the UID" + + def test_key_file_documents_expiry(self): + content = _read("packaging/keys/fenris-packaging.asc") + assert "2 year" in content or "2-year" in content or "expiry" in content.lower(), \ + "Key file must document the expiry policy" + + def test_key_file_references_ceremony_doc(self): + content = _read("packaging/keys/fenris-packaging.asc") + assert "signing-key-ceremony.md" in content, \ + "Key file must reference the key ceremony document" + + +# --------------------------------------------------------------------------- +# Tests — key ceremony documentation +# --------------------------------------------------------------------------- + +class TestKeyCeremonyDoc: + """Verify the key ceremony document is complete and accurate.""" + + def _doc_content(self) -> str: + return _read("docs/install/signing-key-ceremony.md") + + def test_ceremony_doc_exists(self): + assert (REPO_ROOT / "docs" / "install" / "signing-key-ceremony.md").exists(), \ + "docs/install/signing-key-ceremony.md must exist" + + def test_documents_key_specification(self): + content = self._doc_content() + assert "RSA 3072" in content, "Must document RSA 3072 algorithm" + assert "Fenris Packaging" in content, "Must document the UID" + assert "packaging@bongbetic.com" in content, "Must document the email" + + def test_documents_import_sign_delete(self): + content = self._doc_content() + assert "import" in content.lower(), "Must document import step" + assert "sign" in content.lower(), "Must document sign step" + assert "delete" in content.lower(), "Must document delete step" + + def test_documents_rotation_outline(self): + content = self._doc_content() + assert "rotation" in content.lower(), \ + "Must document key rotation procedure" + + def test_documents_dual_key_approach(self): + content = self._doc_content() + assert "previous" in content.lower() or "old" in content.lower(), \ + "Must document old key retention during rotation" + + def test_documents_private_key_storage(self): + content = self._doc_content() + assert "password manager" in content.lower(), \ + "Must document that private key lives in password manager" + + +# --------------------------------------------------------------------------- +# Tests — consumer setup documentation +# --------------------------------------------------------------------------- + +class TestConsumerDocs: + """Verify consumer setup docs are present and correctly wired.""" + + def _readme_content(self) -> str: + return _read("README.md") + + def test_apt_signed_by_flow(self): + content = self._readme_content() + assert "signed-by" in content, \ + "README must document apt signed-by keyring flow" + assert "keyrings" in content, \ + "README must show the keyrings directory" + + def test_apt_fingerprint_placeholder(self): + content = self._readme_content() + assert "Fingerprint" in content or "fingerprint" in content, \ + "README must include fingerprint placeholder for TOFU hardening" + + def test_dnf_repo_flow(self): + content = self._readme_content() + assert "dnf config-manager --add-repo" in content or \ + "dnf install" in content, \ + "README must document dnf install flow" + assert "fenris.repo" in content, \ + "README must reference the Fenris-owned repo file" + + def test_no_gitea_auto_repo(self): + """Gitea's auto-generated .repo must never be referenced in docs.""" + content = self._readme_content() + # The Gitea auto-generated repo would have gpgcheck=1 against the + # instance key, which is a trap. Our docs should only reference + # our own fenris.repo file. + assert "auto-generated" not in content.lower() or \ + "never" in content.lower(), \ + "README must not recommend Gitea's auto-generated .repo" + + def test_package_signature_verification(self): + content = self._readme_content() + assert "rpm -K" in content or "rpm --checksig" in content, \ + "README must document RPM signature verification" + assert "gpg --verify" in content, \ + "README must document GPG verification for SHA256SUMS" + + def test_migration_from_make_install(self): + content = self._readme_content() + assert "migrate-from-makeinstall" in content.lower() or \ + "migration" in content.lower(), \ + "README must reference the migration runbook" + + +# --------------------------------------------------------------------------- +# Tests — release spec references +# --------------------------------------------------------------------------- + +class TestReleaseSpecReferences: + """Verify the release spec references the ceremony doc and nfpm config.""" + + def _spec_content(self) -> str: + return _read("docs/spec/release-packaging.md") + + def test_spec_references_rpmsign(self): + content = self._spec_content() + assert "rpmsign" in content.lower() or "sign-rpm" in content, \ + "Spec must reference rpmsign or make sign-rpm for RPM signing" + + def test_spec_references_ceremony_doc(self): + content = self._spec_content() + assert "signing-key-ceremony.md" in content, \ + "Spec must reference the key ceremony document" + + +# --------------------------------------------------------------------------- +# Tests — RPM signature mechanics (throwaway test key, no network) +# --------------------------------------------------------------------------- + +class TestRpmSignatureMechanics: + """Verify RPM signing mechanics using a throwaway test key. + + These tests generate a temporary GPG key, build an RPM (or use an + existing one), sign it, and verify the signature — all without + network access. They require gpg and rpmsign to be available. + """ + + @pytest.mark.skipif( + not _gpg_available() or not _rpmsign_available(), + reason="gpg or rpmsign not available", + ) + def test_throwaway_key_signs_and_verifies(self): + """Generate a throwaway key, sign a test RPM, verify signature.""" + # Find existing RPM + version = None + for line in (REPO_ROOT / "pyproject.toml").read_text().splitlines(): + if line.startswith("version"): + version = line.split("=")[1].strip().strip('"') + break + rpm_path = REPO_ROOT / "dist" / f"fenris-{version}-1.x86_64.rpm" + if not rpm_path.exists(): + pytest.skip("RPM not built — run `make package-rpm` first") + + key_uid = "fenris-test-signing@example.com" + try: + # Generate throwaway key + subprocess.run( + ["gpg", "--batch", "--gen-key"], + input=f"""%no-protection +Key-Type: RSA +Key-Length: 3072 +Name-Real: {key_uid} +Name-Email: {key_uid} +Expire-Date: 0 +%commit +""", + text=True, check=True, timeout=30, + ) + + # Copy RPM to temp dir for signing + with tempfile.TemporaryDirectory() as tmpdir: + signed_rpm = Path(tmpdir) / rpm_path.name + signed_rpm.write_bytes(rpm_path.read_bytes()) + + # Sign the RPM + subprocess.run( + ["rpmsign", "--addsign", + "--define", f"_gpg_name {key_uid}", + str(signed_rpm)], + check=True, timeout=30, + ) + + # Verify the signature exists and has correct format + # (rpm -Kv returns NOKEY if key isn't imported, but the + # signature header is still present and verifiable) + r = subprocess.run( + ["rpm", "-Kv", str(signed_rpm)], + capture_output=True, text=True, timeout=10, + ) + output = r.stdout + r.stderr + assert "RSA" in output or "rsa" in output.lower(), \ + f"RPM must have RSA signature: {output}" + assert "SHA256" in output or "sha256" in output.lower(), \ + f"RPM must have SHA256 digest: {output}" + assert "Header V4" in output or "Header" in output, \ + f"RPM must have V4 signature header: {output}" + assert "Signature" in output, \ + f"RPM must show signature info: {output}" + + finally: + # Clean up the test key + subprocess.run( + ["gpg", "--batch", "--yes", "--delete-secret-keys", key_uid], + capture_output=True, timeout=5, + ) + subprocess.run( + ["gpg", "--batch", "--yes", "--delete-keys", key_uid], + capture_output=True, timeout=5, + ) + + @pytest.mark.skipif( + not _gpg_available(), + reason="gpg not available", + ) + def test_clearsign_and_verify(self): + """Clearsign a test manifest and verify the signature.""" + key_uid = "fenris-test-clearsign@example.com" + try: + # Generate throwaway key + subprocess.run( + ["gpg", "--batch", "--gen-key"], + input=f"""%no-protection +Key-Type: RSA +Key-Length: 3072 +Name-Real: {key_uid} +Name-Email: {key_uid} +Expire-Date: 0 +%commit +""", + text=True, check=True, timeout=30, + ) + + with tempfile.TemporaryDirectory() as tmpdir: + sums = Path(tmpdir) / "SHA256SUMS" + sums.write_text( + "abc123 fenris_0.3.0_amd64.deb\n" + "def456 fenris-0.3.0-1.x86_64.rpm\n" + ) + + # Clearsign + subprocess.run( + ["gpg", "--batch", "--yes", "--clearsign", + "--local-user", key_uid, str(sums)], + check=True, timeout=10, + ) + + # Verify (clearsigned file — just one argument to --verify) + r = subprocess.run( + ["gpg", "--verify", str(sums.with_suffix(".asc"))], + capture_output=True, text=True, timeout=10, + ) + assert r.returncode == 0, \ + f"Clearsign verification failed: {r.stderr}" + assert "Good signature" in r.stderr, \ + f"Expected Good signature: {r.stderr}" + + finally: + subprocess.run( + ["gpg", "--batch", "--yes", "--delete-secret-keys", key_uid], + capture_output=True, timeout=5, + ) + subprocess.run( + ["gpg", "--batch", "--yes", "--delete-keys", key_uid], + capture_output=True, timeout=5, + )