Files
voculinux/tests/test_void_signed_repo.py
xavierk 731b919865
CI / security (push) Successful in 1m11s
CI / lint (push) Successful in 21s
CI / ai-review (push) Skipped
chore(release): prepare v0.19.0 (#53)
Version 0.19.0 across version.py, the Void template and the synced docs; changelog entry; Gitea project URLs in pyproject.toml; per-backend ladder thresholds measured in #19; the xbps release workflow is manual-only, and packaging/void/publish-release.sh publishes a locally built, verified repository to the Gitea releases.
2026-11-06 20:23:14 +00:00

308 lines
14 KiB
Python

"""Offline policy guards for the signed Void xbps repository (ticket #29).
Spec #16 serves the Void templates from a signed personal xbps repository.
The release path itself (build, sign, install smoke) is guarded in
``tests/test_void_release_build.py``; this module guards what must stay true
around it: the committed public key and its pinned fingerprint, the README and
root README registration text users copy, the release workflow's publish target,
the template bump documented as a pull request, and the rule that no private key
is ever committed.
Seams under test (public interfaces, not internals):
- ``packaging/void/repository-key.plist``: the committed PUBLIC half of the
repository key. Its fingerprint is what the READMEs pin.
- ``README.md`` and ``packaging/void/README.md``: the client registration text.
- ``.gitea/workflows/xbps-release.yml``: the tag trigger, the Gitea API it
publishes to, and the release path it calls.
- ``docs/RELEASE_PROCESS.md``: the template bump is a pull request.
Independent sources of truth: the xbps fingerprint definition (the MD5 of the
ssh-rsa blob of the public key, per libxbps ``xbps_pubkey2fp``), computed here
with the standard library only, and ``packaging/void/smoke.sh`` owns the package
order the signed repository must agree with.
"""
import base64
import hashlib
import plistlib
import re
import struct
from pathlib import Path
from typing import Tuple
import yaml
REPO_ROOT = Path(__file__).resolve().parents[1]
RELEASE_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "release.yml"
GITEA_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "xbps-release.yml"
KEY_PLIST = REPO_ROOT / "packaging" / "void" / "repository-key.plist"
ROOT_README = REPO_ROOT / "README.md"
RELEASE_PROCESS = REPO_ROOT / "docs" / "RELEASE_PROCESS.md"
VOID = REPO_ROOT / "packaging" / "void"
SRC = VOID / "srcpkgs"
BUILD_RELEASE = VOID / "build-release.sh"
CONTAINER_RELEASE = VOID / "container-release.sh"
SMOKE_SH = VOID / "smoke.sh"
PINS = VOID / "tool_checksums.txt"
JUSTFILE = REPO_ROOT / "justfile"
README = VOID / "README.md"
#: The CI secret carrying the PEM-encoded RSA4096 repository private key.
SIGNING_SECRET = "XBPS_SIGNING_KEY"
#: The host-side path variable pointing at the key file. A file (not the
#: secret content on the command line) keeps newlines intact and the key
#: out of process listings.
KEY_FILE_VAR = "XBPS_SIGNING_KEY_FILE"
def _srcpkg_templates() -> list:
"""Every template directory under srcpkgs, in dependency build order."""
return sorted(p.name for p in SRC.iterdir() if (p / "template").is_file())
def _smoke_packages() -> list:
"""The ordered package list the smoke gate builds, from smoke.sh."""
match = re.search(r'^PKGS="([^"]+)"', SMOKE_SH.read_text(encoding="utf-8"), re.M)
assert match, "smoke.sh must declare its ordered PKGS list"
return match.group(1).split()
def test_readme_documents_the_signed_repo_flow() -> None:
"""Maintainer key setup, CI secret, fail-closed rule, and client setup."""
text = README.read_text(encoding="utf-8")
assert "build-release.sh" in text, "README must document the release command"
assert "Runner requirements" in text, "README must document the runner requirements"
assert "4096" in text, "README must document RSA4096 key generation"
assert SIGNING_SECRET in text, "README must name the CI secret"
assert (
"fail" in text.lower() and "closed" in text.lower()
), "README must state the fail-closed rule: no key, no repo"
assert ".plist" in text, "README must document the client key install"
def _release_workflow_text() -> str:
assert RELEASE_WORKFLOW.is_file(), f"missing release workflow: {RELEASE_WORKFLOW}"
return RELEASE_WORKFLOW.read_text(encoding="utf-8")
def _gitea_workflow_text() -> str:
assert GITEA_WORKFLOW.is_file(), f"missing Gitea xbps release workflow: {GITEA_WORKFLOW}"
return GITEA_WORKFLOW.read_text(encoding="utf-8")
def test_the_xbps_job_lives_in_the_gitea_workflow_not_the_github_release() -> None:
"""One signing path: the manual Gitea workflow runs build-release.sh, and no tag starts it."""
github = _release_workflow_text()
for marker in (
"build-xbps",
"xbps-repo",
"XBPS_SIGNING_KEY",
"packaging/void/build-release.sh",
):
assert marker not in github, f"release.yml must not carry the xbps job ({marker})"
gitea = _gitea_workflow_text()
assert (
"packaging/void/build-release.sh" in gitea
), "the Gitea workflow must run the release path"
assert SIGNING_SECRET in gitea, "the Gitea workflow must read the XBPS_SIGNING_KEY secret"
assert "podman" in gitea, "the Gitea workflow must run the pipeline with podman"
workflow = yaml.safe_load(gitea)
assert set(workflow[True]) == {"workflow_dispatch"}, "manual dispatch is the only trigger"
#: Registration URL every client snippet names: the rolling ``latest``
#: release the Gitea workflow keeps current, so the URL serves the newest
#: release's downloads (the tag itself is not part of the client config).
CONCRETE_REPO_DOWNLOAD = "https://git.bongbetic.com/xavierk/voculinux/releases/download/latest"
REGISTRATION_LINE = f"repository={CONCRETE_REPO_DOWNLOAD}"
def _void_section(readme: str) -> str:
"""The root README's Void Linux section, up to the next ``###`` heading."""
start = readme.index("### Void Linux")
end = readme.find("\n### ", start + 1)
return readme[start : end if end != -1 else len(readme)]
def test_client_registration_names_the_rolling_latest_release_url() -> None:
"""No placeholder, no per-tag or GitHub URL: the registration line is the
fork's Gitea `latest` release, which the workflow republishes every tag."""
root = ROOT_README.read_text(encoding="utf-8")
void_readme = README.read_text(encoding="utf-8")
for name, text in (
("release.yml", _release_workflow_text()),
("xbps-release.yml", _gitea_workflow_text()),
("README.md", root),
("packaging/void/README.md", void_readme),
):
assert (
"<repo-url-from-release-notes>" not in text
), f"{name} still carries the self-referential repo-URL placeholder"
assert "repository=<" not in text, f"{name} still carries a placeholder repository URL"
for name, text in (
("README.md", _void_section(root)),
("packaging/void/README.md", void_readme),
):
assert REGISTRATION_LINE in text, f"{name} must carry the registration line"
assert "releases/download/${TAG}" not in text, f"{name} must not pin a per-tag URL"
assert "github.com/VocaHQ/vocalinux/releases" not in text, f"{name} points at upstream"
def test_registration_url_is_the_release_the_workflow_refreshes() -> None:
"""The URL clients register and the API the workflow publishes to must
name the same repository and the same `latest` tag."""
workflow = yaml.safe_load(_gitea_workflow_text())
api = workflow["jobs"]["xbps-release"]["env"]["GITEA_API"]
host, _, rest = api.partition("/api/v1/repos/")
assert CONCRETE_REPO_DOWNLOAD == f"{host}/{rest}/releases/download/latest"
assert "ensure_release latest" in _gitea_workflow_text()
def test_root_readme_registers_the_void_repo() -> None:
"""Root README: Void install from the signed repo, not just the track README."""
text = ROOT_README.read_text(encoding="utf-8")
assert "xbps" in text.lower(), "root README must name xbps"
for marker in ("FINGERPRINT", ".plist", "repository="):
assert marker in text, f"root README must document client registration ({marker})"
assert "xbps-install" in text, "root README must give the install command"
def test_root_readme_shows_the_fingerprint_beside_the_registration() -> None:
"""The fingerprint to pin sits in the Void section, with the registration
line and the install command, not somewhere a reader must hunt for."""
section = _void_section(ROOT_README.read_text(encoding="utf-8"))
assert _key_fingerprint() in section, "the Void section must show the fingerprint to pin"
assert REGISTRATION_LINE in section
assert "xbps-install" in section
assert "-Sy" not in section, "the first sync must not auto-accept an unknown key"
assert "third-party" in section.lower() or "third party" in section.lower()
def test_root_readme_states_void_scope_and_third_party_caveat() -> None:
"""glibc x86_64 only, and this is a third-party repo outside upstream Void."""
text = ROOT_README.read_text(encoding="utf-8").lower()
assert "x86_64" in text, "root README must scope the repo to x86_64"
assert "glibc" in text, "root README must scope the repo to glibc"
assert (
"third-party" in text or "third party" in text
), "root README must say the repo is third-party, not upstream Void"
def test_release_process_documents_void_template_bump_as_pr() -> None:
"""Template version/checksum bumps land as a pull request, never direct to main."""
text = RELEASE_PROCESS.read_text(encoding="utf-8")
assert (
"srcpkgs" in text or "xbps" in text.lower()
), "docs/RELEASE_PROCESS.md must cover the Void template bump"
assert re.search(
r"[Pp]ull request", text
), "docs/RELEASE_PROCESS.md must require the bump as a pull request"
def _der(data: bytes, pos: int) -> Tuple[int, bytes, int]:
"""Read one DER TLV at ``pos``: (tag, value, position after it)."""
tag, first = data[pos], data[pos + 1]
pos += 2
length = first
if first & 0x80:
count = first & 0x7F
length = int.from_bytes(data[pos : pos + count], "big")
pos += count
return tag, data[pos : pos + length], pos + length
def _rsa_public_numbers(pem: str) -> Tuple[int, int]:
"""(modulus, exponent) from an RSA SubjectPublicKeyInfo PEM, stdlib only."""
body = "".join(line for line in pem.splitlines() if not line.startswith("-----"))
tag, spki, _ = _der(base64.b64decode(body), 0)
assert tag == 0x30, "SubjectPublicKeyInfo must be a SEQUENCE"
_, _, after_algorithm = _der(spki, 0)
tag, bits, _ = _der(spki, after_algorithm)
assert tag == 0x03 and bits[0] == 0, "expected a BIT STRING holding the RSAPublicKey"
tag, sequence, _ = _der(bits[1:], 0)
assert tag == 0x30
tag, modulus, after_modulus = _der(sequence, 0)
assert tag == 0x02
tag, exponent, _ = _der(sequence, after_modulus)
assert tag == 0x02
return int.from_bytes(modulus, "big"), int.from_bytes(exponent, "big")
def _mpint(value: int) -> bytes:
"""SSH ``mpint``: length-prefixed big-endian, 0x00 pad when the top bit is set."""
raw = value.to_bytes((value.bit_length() + 7) // 8, "big")
if raw[0] & 0x80:
raw = b"\x00" + raw
return struct.pack(">I", len(raw)) + raw
def _public_pem() -> str:
assert KEY_PLIST.is_file(), f"missing committed repository public key: {KEY_PLIST}"
entry = plistlib.loads(KEY_PLIST.read_bytes())
return entry["public-key"].decode("ascii")
def _key_fingerprint() -> str:
"""The xbps fingerprint of the committed key: MD5 of the ssh-rsa blob."""
modulus, exponent = _rsa_public_numbers(_public_pem())
blob = struct.pack(">I", 7) + b"ssh-rsa" + _mpint(exponent) + _mpint(modulus)
# An identifier xbps defines as MD5, not a security use of the hash.
digest = hashlib.md5(blob, usedforsecurity=False).hexdigest()
return ":".join(digest[i : i + 2] for i in range(0, len(digest), 2))
def test_committed_repository_key_is_a_public_rsa4096_key_only() -> None:
"""Only the public half is ever committed: never private key material."""
raw = KEY_PLIST.read_text(encoding="utf-8")
pem = _public_pem()
assert "PRIVATE KEY" not in raw and "PRIVATE KEY" not in pem
assert pem.startswith("-----BEGIN PUBLIC KEY-----")
entry = plistlib.loads(KEY_PLIST.read_bytes())
assert set(entry) == {"public-key", "public-key-size", "signature-by"}
modulus, exponent = _rsa_public_numbers(pem)
assert modulus.bit_length() == 4096, "the repository key must be RSA 4096"
assert entry["public-key-size"] == 4096
assert exponent == 65537
def test_key_fingerprint_follows_the_xbps_format() -> None:
fingerprint = _key_fingerprint()
assert re.fullmatch(r"([0-9a-f]{2}:){15}[0-9a-f]{2}", fingerprint), fingerprint
def test_readmes_pin_the_fingerprint_of_the_committed_key() -> None:
"""Users compare against the README, so it must be derived, not typed."""
fingerprint = _key_fingerprint()
for name, path in (
("README.md", ROOT_README),
("packaging/void/README.md", README),
):
assert fingerprint in path.read_text(encoding="utf-8"), f"{name} must show {fingerprint}"
def test_void_readme_documents_the_secret_and_the_public_key_only_rule() -> None:
text = README.read_text(encoding="utf-8")
assert "repository-key.plist" in text, "README must name the committed public key"
assert "never committed" in text.lower() or "never commit" in text.lower()
assert "openssl genrsa" in text and "4096" in text
assert SIGNING_SECRET in text
def test_the_private_key_is_never_committed() -> None:
"""Only the public half lives in the repository: no PEM private key block in
the Void packaging tree or the workflows that sign with it."""
private_block = re.compile(rb"-----BEGIN [A-Z ]*PRIVATE KEY-----")
roots = (
REPO_ROOT / "packaging" / "void",
REPO_ROOT / ".gitea",
REPO_ROOT / ".github",
)
scanned = 0
for root in roots:
for path in root.rglob("*"):
if path.is_file():
scanned += 1
assert not private_block.search(path.read_bytes()), f"private key in {path}"
assert scanned > 20, "the scan must actually cover the packaging and workflow trees"