feat(void): pin the repository key and register the rolling latest release

Commit the public half of the repository signing key as
packaging/void/repository-key.plist and show its fingerprint beside the
registration lines in the root README and the Void packaging README. The
test derives the fingerprint from the committed key with the xbps
definition (MD5 of the ssh-rsa blob) instead of trusting typed text, checks
the key is 4096-bit public material only, and ties the README, the
registration URL and the release workflow to it.

Clients now register the fork's rolling latest release on Gitea instead of
a per-tag upstream GitHub URL, and the first sync no longer passes -y, so an
unknown key is never accepted silently. The Void README also documents key
generation, the XBPS_SIGNING_KEY secret, the tag workflow and the pending
app-template checksum.
This commit is contained in:
2026-11-06 18:27:34 +05:30
parent ba6462ccf8
commit 3c33aa62b8
4 changed files with 326 additions and 108 deletions
+20 -13
View File
@@ -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 <fingerprint>.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
+99 -33
View File
@@ -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 <soubarna@live.in>`.
`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 <fingerprint>.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.
+12
View File
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple Computer//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>public-key</key>
<data>LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUlJQ0lqQU5CZ2txaGtpRzl3MEJBUUVGQUFPQ0FnOEFNSUlDQ2dLQ0FnRUE5dDhGbEVPUXNaUDZROHNhaXlZMQpRSkhpQ1A5ZU00aWd3R2xRQ1FxMncxOVlkOURza2wxNTdsZEJFTHJBWkVVV2orYjJYODVHc0xKZFNGbHRGaTRUClZFUGpIUlZpbVdFU3V1MG0zK28zK3V2d0R4LzlaZHRMK01aZUIwWTBHZThFSGM1aVFCSk9NSnVSZTlzWERQSEwKclB6ZUZ3NFE4SEYyWGdEVXpvNUZncVd4TEIyQ0xleEhFVGhxNHNSWWFtUU85OE9JS0xWUWVSbEpBOVU4RXBoRgp3QUQ5R3BBR1FxUnBZMTRYV09nNmIva2RpMUhjeWxMZ1p0YVoyQzE1TW5KT2lTanpTTjRKdDdmdHg2czNKRGJzCmpPTjRsWXZxakFETkZQdEFKbmxlUlVHSEwxL20xa3VMbG11eFUyVThkOGJGK1dxUGFKem1oQUcvSHBNdkJqOXMKTjFSYUE0RkhIZ2pueUc4dHJibzQyUjByN1BGb1p6T1g2VWNzSmRMaUZVMFpqRll2QVh6MmViMmlvUjR0eTNWOQoxb0UvbFBsaGQ5SzFPdmZBazRFRVdYMHhCcThlMkEzczZSNG9jWDdlNzFJY2QwbVowRjVTMDhmR1ZlamdvTm5hCmJBcXRQaXdFZGdoVVExT0FGdS9VQmlxdkloODJNbmphbVNGN25BaUw4UldxMFZiSlZ5bGNqOVVtV0R5N3FWam8KTjFkWFRHNGcrT3AyM2JpZlVxMDJzRERzVXhZd3ptdHVtVENHczlSWlQ2N0JpNXRRRU1DZy9ZbUhkRkllbnEzUwpRSVNpYlNWejA2T3M1U3RJZHRlM2dEcjBROHRLYnJzRVk1Q1IwSnM1d1hYOFJvZ3MwbVArRXV2OVByNTNNT1k0CllidXpENEMzWWcvNkhjelY0WHkwOGVjQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=</data>
<key>public-key-size</key>
<integer>4096</integer>
<key>signature-by</key>
<string>Bongbetic &lt;soubarna@live.in&gt;</string>
</dict>
</plist>
+195 -62
View File
@@ -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 (
"<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"
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"