"""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, )