diff --git a/README.md b/README.md index c177c8d..43e75ff 100644 --- a/README.md +++ b/README.md @@ -238,30 +238,37 @@ The flake also ships `nixosModules.voculinux` and `homeManagerModules.voculinux` ### Void Linux (signed xbps repo) -Void Linux **glibc `x86_64` only**. Each tagged release attaches a signed -personal xbps repository: binary packages (`*.x86_64.xbps`), signatures, +Void Linux **glibc `x86_64` only**. Each version tag publishes a signed +personal xbps repository to the project's rolling `latest` release: +binary packages (`*.x86_64.xbps`) with their signatures, the signed repository metadata (`x86_64-repodata`), and the client key -(`FINGERPRINT` plus the fingerprint-named `.plist`). +(`FINGERPRINT` plus the fingerprint-named `.plist`). The registration URL +below always serves the newest release. This is a **third-party repository** — it is published by this project, not by upstream Void Linux, and its packages are built from the templates in `packaging/void/srcpkgs/`. Details: [packaging/void/README.md](packaging/void/README.md). -```bash -# as root on the Void system (the files come from the release assets): -cp .plist /var/db/xbps/keys/ -TAG=v0.18.5 # match the release you install from -echo "repository=https://github.com/VocaHQ/vocalinux/releases/download/${TAG}/" > /etc/xbps.d/voculinux.conf +Repository key, RSA 4096. Pin this fingerprint: + +```text +fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25 ``` -Prefer the release notes for the exact repository URL. On first sync, -answer `Y` at the fingerprint prompt only after checking it matches the -`FINGERPRINT` file attached to the same release: - ```bash -sudo xbps-install -Sy voculinux +# as root on the Void system: trust the key, register the repository, install +curl -fsSL -o '/var/db/xbps/keys/fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25.plist' \ + https://git.bongbetic.com/xavierk/voculinux/raw/branch/main/packaging/void/repository-key.plist +echo 'repository=https://git.bongbetic.com/xavierk/voculinux/releases/download/latest' > /etc/xbps.d/voculinux.conf +xbps-install -S voculinux ``` +The key file is named by its fingerprint, so xbps finds it on the first +sync and asks nothing. If xbps still shows a fingerprint prompt, answer +`N` unless it reads exactly the fingerprint above (it must also match the +`FINGERPRINT` file attached to the release). Do not add `-y` to the first +sync: it would accept an unknown key without asking. + ### From source ```bash diff --git a/packaging/void/README.md b/packaging/void/README.md index 61827fb..384999c 100644 --- a/packaging/void/README.md +++ b/packaging/void/README.md @@ -14,6 +14,7 @@ packaging/void/ ├── smoke.sh ticket #28 gate: every template, one command ├── repo.sh ticket #29 pipeline: build all, index, sign ├── container-repo.sh ticket #29 container half (index + sign + verify) +├── repository-key.plist the repository's PUBLIC key (never a private key) ├── tool_checksums.txt pinned base image and void-packages commit └── srcpkgs/ ├── python3-pywhispercpp/ ticket #26: whisper.cpp Python binding @@ -68,40 +69,49 @@ for the sdist; `tests/test_void_pywhispercpp_template.py` fails otherwise. ## voculinux Version `0.18.5`, matching `src/vocalinux/version.py` — never newest -upstream. The template builds the canonical release source distribution -(the `releases/download` sdist, the same bytes published to PyPI) with a -pinned `checksum`; `tests/test_void_app_template.py` fails otherwise. -The 0.18.5 asset still carries the pre-rename archive root; xbps-src -moves the single top-level directory to `pkgname-version` itself, so no -`wrksrc` override is needed (`wrksrc` is deprecated and `xlint` rejects -it). The first release cut after the rename points `distfiles` at the -renamed asset. +upstream. The template builds the fork's canonical release source +distribution, `voculinux-0.18.5.tar.gz`, attached to the fork's `v0.18.5` +release on git.bongbetic.com, with a pinned `checksum` (see +[Checksum](#checksum)); `tests/test_void_app_template.py` fails if the +version drifts from `version.py`. -- **Runtime deps** are Void's own interpreter, GTK and desktop bindings - (`python3`, `python3-gobject`, `python3-cairo`, `gtk+3`, - `libayatana-appindicator`, `ibus`, `gobject-introspection`), the - packaged Python libraries, and the three templates from tickets #25 - and #26 (`python3-pynput`, `python3-PyAudio`, `python3-pywhispercpp`). - Every name was verified to exist in current void-packages; the +- **Runtime deps** are Void's own interpreter, GTK and desktop bindings, + the packaged Python libraries, the three templates from tickets #25 + and #26 (`python3-pynput`, `python3-PyAudio`, `python3-pywhispercpp`), + and the helper tools the app shells out to: PipeWire (capture), `wtype` + (direct typing on niri), `wl-clipboard`, `xdotool` and + `pulseaudio-utils`. `ibus` is not a dependency: it cannot inject on + niri. Every name was verified to exist in current void-packages; the container build re-verifies via dependency resolution. - **No payload beyond the app**: no speech models (they download on first run), no vendored wheels, no pip at build or install time. The container smoke fails the build if the payload carries model weights or wheels. -- **Renamed paths**: the `voculinux.desktop` entry, the hicolor icons, +- **Installed names**: the `voculinux.desktop` entry, the hicolor icons, and a `usr/share/voculinux/packaged` marker the app reads to present - package-manager updates. The 0.18.5 sdist predates the rename, so - `post_install` promotes the old file names at install time; the payload - smoke rejects any pre-rename leftovers in the OS-visible paths. + package-manager updates. The fork's sdist already carries the final + names, so the template installs them as they are; the payload smoke + rejects any pre-rename leftovers in the OS-visible paths. - **INSTALL.msg** covers what xbps cannot express: the `input` group for the evdev hotkey backend, first-run model downloads, optional engines as manual additions (vosk, OpenAI whisper, Parakeet, faster-whisper, - onnxruntime), the absent `ydotool` rung, and updating through xbps. + onnxruntime), the niri first run, the absent `ydotool` rung, and + updating through xbps. - **Update checker**: packaged installs keep background checks as a signal, but every update affordance (tray entry, notification, About page) presents the package-manager path (`src/vocalinux/utils/packaging.py`). +## Checksum + +The template's `checksum=` is `PENDING_FORK_RELEASE_SHA256` until the +fork's `v0.18.5` release asset (`voculinux-0.18.5.tar.gz` on +git.bongbetic.com/xavierk/voculinux) exists. Once it does, set `checksum=` +to the `sha256sum` of that exact downloaded file, never a value copied from +upstream, and land the change in a pull request. Until then `xbps-src` +fails closed on the pending value, so neither the smoke gate nor the signed +repository can build `voculinux`. + ## Building ```sh @@ -137,7 +147,7 @@ so a renamed or added template cannot silently drop out. `packaging/void/repo.sh` builds every template in smoke-gate order, indexes the full repository (`x86_64-repodata`), and signs every binary package -(`*.sig2`) plus the repository metadata with the RSA4096 repository key — +(`*.sig2`) plus the repository metadata with the RSA 4096 repository key — one command, same pinned container via podman, no host Void install: ```sh @@ -156,29 +166,85 @@ and a `FINGERPRINT` file carrying the signer identity and fingerprint for release notes). `publish/` is local build output and stays uncommitted. Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before any -container runs, so an unsigned repository can never publish. CI holds the -PEM-encoded key in the `XBPS_SIGNING_KEY` secret and writes it to a file -first (a file keeps newlines intact and the key out of process listings). +container runs, so an unsigned repository can never publish. -**Maintainer key setup (one time):** +### The repository key + +One RSA 4096 key signs everything. Its fingerprint, which clients pin: + +```text +fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25 +``` + +- **Public half**: committed as `packaging/void/repository-key.plist` + (the same file xbps keeps in `/var/db/xbps/keys/`, named by the + fingerprint). Signer identity in the file: `Bongbetic `. + `tests/test_void_signed_repo.py` derives the fingerprint from this file + and fails if either README, or the release workflow, shows a different one. +- **Private half**: never committed, never in the repository, never on a + build host's disk beyond the run. It exists as the `XBPS_SIGNING_KEY` + CI secret (PEM text, newlines intact) and in the maintainer's own backup. +- **Generating a new key** (only if the key is ever replaced; clients then + have to re-pin, so this is a release-notes event): ```sh openssl genrsa -out xbps-sign-key.pem 4096 # back the key up somewhere safe, then store it as XBPS_SIGNING_KEY +openssl rsa -in xbps-sign-key.pem -pubout # the public half, to build the .plist from ``` -**Client setup:** +- **Checking a private key against the committed public key**, before it is + stored as the secret (both lines must print the same digest): ```sh -# as root on the Void system (the release notes carry the repo URL): -cp .plist /var/db/xbps/keys/ -TAG=v0.18.5 # match the release you install from -echo "repository=https://github.com/VocaHQ/vocalinux/releases/download/${TAG}/" > /etc/xbps.d/voculinux.conf -xbps-install -Sy voculinux +openssl rsa -in xbps-sign-key.pem -pubout 2>/dev/null | openssl md5 +python3 -c "import plistlib,sys; sys.stdout.buffer.write(plistlib.load(open('packaging/void/repository-key.plist','rb'))['public-key'])" | openssl md5 ``` -Answer `Y` when xbps shows the fingerprint prompt on first sync, and check -it matches the published `FINGERPRINT` file. `tests/test_void_signed_repo.py` +### Releasing: the Gitea workflow + +A human pushes a version tag; `.gitea/workflows/xbps-release.yml` does the +rest. It never runs on a schedule or a manual dispatch. + +1. The tag must equal `v` plus `__version__` in `src/vocalinux/version.py`, + exactly; any other tag exits 1 and publishes nothing. +2. The CI secret `XBPS_SIGNING_KEY` must be set; empty exits 1 before any + container runs. The key is written to a 0600 temp file, handed to + `repo.sh` as `XBPS_SIGNING_KEY_FILE`, and removed by a final step that + always runs. +3. `repo.sh` builds and signs inside the pinned container (podman, never + Docker). +4. The output is checked: every class of file present (`*.x86_64.xbps`, + `*.sig2`, `*-repodata*`, `FINGERPRINT`, the `.plist`), every package + signed, nothing but glibc `x86_64` data, and the signing key's + fingerprint equal to the one above. +5. Everything attaches to the tag's release and to the rolling `latest` + release (replacing that release's old assets), through the Gitea API. + `latest` is what the client registration URL serves. + +The workflow never commits or pushes. Bumping the templates to the new +release is a normal pull request (`docs/RELEASE_PROCESS.md`). + +Secret setup, once: Gitea repository settings, Actions, Secrets, add +`XBPS_SIGNING_KEY` with the PEM private key. The runner (`bongbetic-ci`) +needs podman (the workflow installs it with apt when absent), and the job +token must be allowed to create releases. + +### Client setup + +```sh +# as root on the Void system +curl -fsSL -o '/var/db/xbps/keys/fa:d1:00:32:ce:b1:b9:fd:73:05:05:76:75:54:37:25.plist' \ + https://git.bongbetic.com/xavierk/voculinux/raw/branch/main/packaging/void/repository-key.plist +echo 'repository=https://git.bongbetic.com/xavierk/voculinux/releases/download/latest' > /etc/xbps.d/voculinux.conf +xbps-install -S voculinux +``` + +The key file is named by its fingerprint, so xbps finds it on the first +sync. If xbps shows a fingerprint prompt anyway, answer `N` unless it +reads exactly the fingerprint above and matches the `FINGERPRINT` file on +the release; do not add `-y` to the first sync. `tests/test_void_signed_repo.py` asserts offline that the pipeline stays wired to every `srcpkgs` template, stays podman-only on the pinned inputs, and keeps the fail-closed key -contract. +contract; `tests/test_xbps_release_workflow.py` runs the workflow's gates +against fixtures. diff --git a/packaging/void/repository-key.plist b/packaging/void/repository-key.plist new file mode 100644 index 0000000..877083c --- /dev/null +++ b/packaging/void/repository-key.plist @@ -0,0 +1,12 @@ + + + + + public-key + LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUE5dDhGbEVPUXNaUDZROHNhaXlZMQpRSkhpQ1A5ZU00aWd3R2xRQ1FxMncxOVlkOURza2wxNTdsZEJFTHJBWkVVV2orYjJYODVHc0xKZFNGbHRGaTRUClZFUGpIUlZpbVdFU3V1MG0zK28zK3V2d0R4LzlaZHRMK01aZUIwWTBHZThFSGM1aVFCSk9NSnVSZTlzWERQSEwKclB6ZUZ3NFE4SEYyWGdEVXpvNUZncVd4TEIyQ0xleEhFVGhxNHNSWWFtUU85OE9JS0xWUWVSbEpBOVU4RXBoRgp3QUQ5R3BBR1FxUnBZMTRYV09nNmIva2RpMUhjeWxMZ1p0YVoyQzE1TW5KT2lTanpTTjRKdDdmdHg2czNKRGJzCmpPTjRsWXZxakFETkZQdEFKbmxlUlVHSEwxL20xa3VMbG11eFUyVThkOGJGK1dxUGFKem1oQUcvSHBNdkJqOXMKTjFSYUE0RkhIZ2pueUc4dHJibzQyUjByN1BGb1p6T1g2VWNzSmRMaUZVMFpqRll2QVh6MmViMmlvUjR0eTNWOQoxb0UvbFBsaGQ5SzFPdmZBazRFRVdYMHhCcThlMkEzczZSNG9jWDdlNzFJY2QwbVowRjVTMDhmR1ZlamdvTm5hCmJBcXRQaXdFZGdoVVExT0FGdS9VQmlxdkloODJNbmphbVNGN25BaUw4UldxMFZiSlZ5bGNqOVVtV0R5N3FWam8KTjFkWFRHNGcrT3AyM2JpZlVxMDJzRERzVXhZd3ptdHVtVENHczlSWlQ2N0JpNXRRRU1DZy9ZbUhkRkllbnEzUwpRSVNpYlNWejA2T3M1U3RJZHRlM2dEcjBROHRLYnJzRVk1Q1IwSnM1d1hYOFJvZ3MwbVArRXV2OVByNTNNT1k0CllidXpENEMzWWcvNkhjelY0WHkwOGVjQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo= + public-key-size + 4096 + signature-by + Bongbetic <soubarna@live.in> + + diff --git a/tests/test_void_signed_repo.py b/tests/test_void_signed_repo.py index 34f5574..96b5cad 100644 --- a/tests/test_void_signed_repo.py +++ b/tests/test_void_signed_repo.py @@ -19,8 +19,13 @@ Seams under test (public interfaces, not internals): pipeline, same shape as the other packaging gates. - ``packaging/void/README.md`` — the documented maintainer and client flow, including the CI secret name. +- ``packaging/void/repository-key.plist`` — the committed PUBLIC half of + the repository key, whose fingerprint the READMEs and the Gitea release + workflow pin. ``tests/test_xbps_release_workflow.py`` runs the workflow. -Independent sources of truth: ``packaging/void/srcpkgs/*/template`` +Independent sources of truth: the xbps fingerprint definition (the MD5 of +the SSH-format ``ssh-rsa`` blob of the public key, per libxbps +``xbps_pubkey2fp``), ``packaging/void/srcpkgs/*/template`` owns the template set (a renamed or added template must not silently drop out), ``packaging/void/tool_checksums.txt`` owns the pins, ``packaging/void/smoke.sh`` owns the dependency order the signed repo @@ -29,12 +34,20 @@ signing mechanics the container half must invoke. The container run itself is the proof; these tests are the wiring guard. """ +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" @@ -86,8 +99,7 @@ def test_pipeline_builds_every_template_and_nothing_else() -> None: "not a second hand-maintained list" ) assert set(_smoke_packages()) == set(_srcpkg_templates()), ( - f"smoke order covers {_smoke_packages()}, " - f"srcpkgs holds {_srcpkg_templates()}" + f"smoke order covers {_smoke_packages()}, " f"srcpkgs holds {_srcpkg_templates()}" ) @@ -104,15 +116,11 @@ def test_pipeline_stays_podman_only_with_pinned_inputs() -> None: (REPO_SH, _repo_text()), (CONTAINER_REPO, _container_text()), ): - assert ( - "podman" in text or path == CONTAINER_REPO - ), f"{path.name} must use podman" + assert "podman" in text or path == CONTAINER_REPO, f"{path.name} must use podman" assert "docker" not in text.lower().replace( "never docker", "" ), f"{path.name} must never invoke Docker" - assert ( - "ghcr.io" not in _repo_text() - ), "the base image pin lives in tool_checksums.txt" + assert "ghcr.io" not in _repo_text(), "the base image pin lives in tool_checksums.txt" assert PINS.is_file(), f"missing pins file: {PINS}" @@ -136,21 +144,15 @@ def test_pipeline_requires_rsa4096_before_any_container_runs() -> None: assert "4096" in text, "repo.sh must pin the 4096-bit key size" assert "openssl" in text, "repo.sh must validate the key with openssl" key_check = text.index("openssl") - first_run = min( - i for marker in ("podman pull", "podman run") if (i := text.find(marker)) >= 0 - ) - assert ( - key_check < first_run - ), "key validation must run before the first podman invocation" + first_run = min(i for marker in ("podman pull", "podman run") if (i := text.find(marker)) >= 0) + assert key_check < first_run, "key validation must run before the first podman invocation" def test_container_signs_packages_and_metadata_with_the_key() -> None: """xbps-rindex signs every binary package and the repository metadata.""" text = _container_text() assert "xbps-rindex -a" in text, "the repo index (full repodata) is missing" - assert ( - "-S" in text or "--sign-pkg" in text - ), "per-package signing (--sign-pkg) is missing" + assert "-S" in text or "--sign-pkg" in text, "per-package signing (--sign-pkg) is missing" assert ( re.search(r"(^|\s)-s(\s|$)", text) or "--sign " in text ), "repository metadata signing (-s/--sign) is missing" @@ -188,9 +190,7 @@ def test_container_proves_signatures_with_a_clean_rootdir_install() -> None: assert ( "xbps-install" in text and "-r " in text ), "container-repo.sh must prove the signed repo with a rootdir install" - assert ( - "voculinux" in text - ), "the proof install must be the app, resolving deps from the repo" + assert "voculinux" in text, "the proof install must be the app, resolving deps from the repo" def test_just_recipe_calls_the_pipeline_like_other_packaging_gates() -> None: @@ -201,20 +201,14 @@ def test_just_recipe_calls_the_pipeline_like_other_packaging_gates() -> None: re.MULTILINE, ) assert recipe, "justfile must define a void-repo recipe" - assert not recipe.group( - 1 - ).strip(), "void-repo must not take a shared-output prerequisite" - assert "packaging/void/repo.sh" in recipe.group( - 2 - ), "void-repo must run packaging/void/repo.sh" + assert not recipe.group(1).strip(), "void-repo must not take a shared-output prerequisite" + assert "packaging/void/repo.sh" in recipe.group(2), "void-repo must run packaging/void/repo.sh" 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 ( - "void-repo" in text or "repo.sh" in text - ), "README must document the signed-repo command" + assert "void-repo" in text or "repo.sh" in text, "README must document the signed-repo command" assert "4096" in text, "README must document RSA4096 key generation" assert SIGNING_SECRET in text, "README must name the CI secret" assert ( @@ -228,6 +222,11 @@ def _release_workflow_text() -> str: 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 tag-triggered Gitea workflow runs repo.sh.""" github = _release_workflow_text() @@ -237,49 +236,61 @@ def test_the_xbps_job_lives_in_the_gitea_workflow_not_the_github_release() -> No "XBPS_SIGNING_KEY", "packaging/void/repo.sh", ): - assert ( - marker not in github - ), f"release.yml must not carry the xbps job ({marker})" - gitea = GITEA_WORKFLOW.read_text(encoding="utf-8") - assert ( - "packaging/void/repo.sh" in gitea - ), "the Gitea workflow must run the signed-repo pipeline" - assert ( - SIGNING_SECRET in gitea - ), "the Gitea workflow must read the XBPS_SIGNING_KEY secret" + assert marker not in github, f"release.yml must not carry the xbps job ({marker})" + gitea = _gitea_workflow_text() + assert "packaging/void/repo.sh" in gitea, "the Gitea workflow must run the signed-repo pipeline" + 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 workflow[True] == {"push": {"tags": ["v*"]}}, "tag pushes are the only trigger" -#: Concrete release-download prefix every client snippet must name. The -#: xbps job attaches the signed repo flat to the tag release with -#: ``gh release upload "${{ github.ref_name }}"``, so the repository URL is -#: the per-tag download prefix below. -CONCRETE_REPO_DOWNLOAD = "https://github.com/VocaHQ/vocalinux/releases/download/" +#: 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 test_client_registration_names_concrete_repo_url() -> None: - """No self-referential placeholder: every client snippet names the - concrete release-download URL the xbps job attaches the repo to.""" - workflow = _release_workflow_text() +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", workflow), + ("release.yml", _release_workflow_text()), + ("xbps-release.yml", _gitea_workflow_text()), ("README.md", root), ("packaging/void/README.md", void_readme), ): assert ( "" 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" - assert ( - CONCRETE_REPO_DOWNLOAD in root - ), "root README must name the concrete release download URL" - assert ( - CONCRETE_REPO_DOWNLOAD in void_readme - ), "void README must name the concrete release download URL" + 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: @@ -287,12 +298,21 @@ def test_root_readme_registers_the_void_repo() -> None: 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 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() @@ -312,3 +332,116 @@ def test_release_process_documents_void_template_bump_as_pr() -> None: 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_workflow_publishes_only_under_the_pinned_fingerprint() -> None: + """A secret holding another key fails the verify step before publishing.""" + workflow = yaml.safe_load(_gitea_workflow_text()) + assert workflow["jobs"]["xbps-release"]["env"]["XBPS_KEY_FINGERPRINT"] == _key_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"