- nfpm.yaml: type:config → config_noreplace (RPM noreplace semantics) - nfpm.yaml: type:ghost → type:dir for /var/lib/fenris (deb compatibility) - postinst.sh/rpm/post.sh: fix timer restart — capture running unit before daemon-reload so the diff actually detects changes - README: fix Python floor to ≥3.10 (was ≥3.9, inconsistent with Makefile) - signing-key-ceremony.md: fix stale claim about nfpm signing RPMs (actual path is post-build rpmsign) - tests/conftest.py: extract shared _get_version() and _read() helpers - tests: wire up to shared conftest helpers - release.yml: extract VERSION once via GITHUB_OUTPUT step Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
481 lines
18 KiB
Python
481 lines
18 KiB
Python
"""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:
|
|
from tests.conftest import read
|
|
return read(path)
|
|
|
|
|
|
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,
|
|
)
|