diff --git a/.gitignore b/.gitignore index 145844c..68e9b53 100644 --- a/.gitignore +++ b/.gitignore @@ -178,3 +178,6 @@ opentree.toml # Nix build output symlinks result result-* + +# Signed xbps repository staging (ticket #29, local build output) +/publish/ diff --git a/justfile b/justfile index 8665f98..8502946 100644 --- a/justfile +++ b/justfile @@ -134,6 +134,15 @@ native-smoke distro="debian:12": void-smoke: bash packaging/void/smoke.sh +# Build every Void template, index the full repository and sign packages +# plus metadata with the RSA4096 repository key, in the pinned Void +# container via podman (never Docker), as ticket #29 pipelines. Fails +# closed without a key: set XBPS_SIGNING_KEY_FILE (CI writes the +# XBPS_SIGNING_KEY secret to a file first). Needs no host Void install; +# run `just void-smoke` first — the repo assembles tested packages. +void-repo: + bash packaging/void/repo.sh + # Build the canonical sdist, prepare an explicitly unattested local PKGBUILD, # then build/install it unchanged on current Arch (needs uv and docker). # Keep distributions and diagnostics away from other packaging commands' outputs. diff --git a/packaging/void/README.md b/packaging/void/README.md index ff9be12..56f076a 100644 --- a/packaging/void/README.md +++ b/packaging/void/README.md @@ -12,6 +12,8 @@ packaging/void/ ├── README.md this file ├── build.sh podman harness: lint + xbps-src build + import smoke ├── 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) ├── tool_checksums.txt pinned base image and void-packages commit └── srcpkgs/ ├── python3-pywhispercpp/ ticket #26: whisper.cpp Python binding @@ -130,3 +132,52 @@ prints `FAIL: ` naming which package broke, above it the harness echoes which stage broke. `tests/test_void_smoke_gate.py` asserts offline that the gate stays wired to every `srcpkgs` template, so a renamed or added template cannot silently drop out. + +## Signed repository + +`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 — +one command, same pinned container via podman, no host Void install: + +```sh +# Run the smoke gate first: the repo assembles tested packages. +packaging/void/smoke.sh +# or: just void-smoke + +printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem +XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem packaging/void/repo.sh +# or: XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem just void-repo +``` + +The pipeline stages `publish/xbps/repo/` (packages, signatures, repodata) +plus `publish/xbps/keys/` (the client `.plist` named by key fingerprint, +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). + +**Maintainer key setup (one time):** + +```sh +openssl genrsa -out xbps-sign-key.pem 4096 +# back the key up somewhere safe, then store it as XBPS_SIGNING_KEY +``` + +**Client setup:** + +```sh +# as root on the Void system (the release notes carry the repo URL): +cp .plist /var/db/xbps/keys/ +echo 'repository=' > /etc/xbps.d/voculinux.conf +xbps-install -Sy voculinux +``` + +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` +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. diff --git a/packaging/void/container-repo.sh b/packaging/void/container-repo.sh new file mode 100755 index 0000000..324857b --- /dev/null +++ b/packaging/void/container-repo.sh @@ -0,0 +1,129 @@ +#!/bin/sh +# Repository half of packaging/void/repo.sh (ticket #29). Runs inside the +# pinned Void container as root: system setup, the void-packages checkout +# at the pinned commit, then every template built in smoke-gate order as +# the unprivileged builder user (xbps-src refuses root), then index, sign, +# verify and stage as root again. +# +# Not executed directly: repo.sh mounts this file and runs it. VOID_COMMIT, +# PKGS and SIGNEDBY arrive via the environment; the RSA4096 private key is +# mounted read-only at /run/xbps-sign-key.pem (repo.sh validated it before +# any container ran). +# +# Keeping this a real file (instead of an inline `sh -c` string) means +# normal quoting, heredocs, and shellcheck apply. +set -eu + +export PATH=/usr/bin:/bin:/usr/sbin:/sbin +KEY=/run/xbps-sign-key.pem +OUT=/mnt/out +REPO="$OUT/repo" +KEYS="$OUT/keys" + +[ -r "$KEY" ] || { + echo "container-repo.sh: signing key not mounted at $KEY" >&2 + exit 1 +} + +xbps-install -Suy xbps >/dev/null +xbps-install -y git base-devel xtools binutils zstd openssl >/dev/null +git clone --quiet --depth 1 https://github.com/void-linux/void-packages /void-packages +git -C /void-packages fetch --quiet --depth 1 origin "$VOID_COMMIT" +git -C /void-packages checkout --quiet "$VOID_COMMIT" +cp -r /overlay/. /void-packages/srcpkgs/ +id builder >/dev/null 2>&1 || useradd -m builder +chown builder:builder /void-packages + +echo "== build every template in order: $PKGS" +cd /void-packages +# shellcheck disable=SC2086 +for PKG in $PKGS; do + echo "== xlint $PKG" + su -s /bin/sh builder -c "cd /void-packages && xlint 'srcpkgs/$PKG/template'" + echo "== xbps-src pkg $PKG" + su -s /bin/sh builder -c "cd /void-packages && ./xbps-src pkg '$PKG'" +done + +echo "== collect built packages" +mkdir -p "$REPO" "$KEYS" +# shellcheck disable=SC2086 +for PKG in $PKGS; do + PKGFILE=$(ls /void-packages/hostdir/binpkgs/"$PKG"-*.x86_64.xbps 2>/dev/null | head -n1) + [ -n "$PKGFILE" ] || { + echo "FAIL: $PKG (built package missing from hostdir/binpkgs)" >&2 + exit 1 + } + echo "collected: $(basename "$PKGFILE")" + cp "$PKGFILE" "$REPO/" +done + +echo "== index the full repository" +# shellcheck disable=SC2086 +xbps-rindex -a "$REPO"/*.x86_64.xbps + +echo "== sign every binary package" +xbps-rindex -S --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO"/*.x86_64.xbps + +echo "== sign the repository metadata" +xbps-rindex -s --privkey "$KEY" --signedby "$SIGNEDBY" "$REPO" + +echo "== prove an unsigned install is refused" +set +e +PROBE=$(xbps-install -r /tmp/keyprobe --repository="$REPO" -Sy voculinux &1) +PROBE_RC=$? +set -e +if [ "$PROBE_RC" -eq 0 ] || ! printf '%s' "$PROBE" | grep -q "RSA signed"; then + echo "FAIL: unsigned install was not refused — signatures are not enforced" >&2 + printf '%s\n' "$PROBE" >&2 + exit 1 +fi +echo "unsigned install refused as required" + +FP=$(printf '%s' "$PROBE" | grep -i Fingerprint | awk '{print $2}' | head -n1) +[ -n "$FP" ] || { + echo "FAIL: could not read the key fingerprint from the refusal" >&2 + exit 1 +} +echo "key fingerprint: $FP" + +echo "== stage the client key" +openssl rsa -in "$KEY" -pubout -out /tmp/repo-pub.pem 2>/dev/null +PUB64=$(base64 -w0 /tmp/repo-pub.pem) +# XML-escape the signer identity: xbps writes </> itself on import +# and rejects a plist carrying raw angle brackets. +ESCAPED_SIGNEDBY=$(printf '%s' "$SIGNEDBY" | sed -e 's/&/\&/g' -e 's//\>/g') +{ + printf '%s\n' '' + printf '%s\n' '' + printf '%s\n' '' '' ' public-key' + printf ' %s\n' "$PUB64" + printf '%s\n' ' public-key-size' ' 4096' ' signature-by' + printf ' %s\n' "$ESCAPED_SIGNEDBY" + printf '%s\n' '' '' +} > "$KEYS/$FP.plist" +{ + printf 'signed-by: %s\n' "$SIGNEDBY" + printf 'fingerprint: %s\n' "$FP" +} > "$KEYS/FINGERPRINT" +echo "staged: keys/$FP.plist" + +echo "== verify: install the app from the signed repo in a clean rootdir" +VERIFY=/tmp/verify-install +mkdir -p "$VERIFY/var/db/xbps/keys" +# The app's full dependency tree resolves from the official remote, so the +# verify rootdir trusts the official keys too; our repo stays enforced +# because its packages verify only against the staged key below. +cp /var/db/xbps/keys/*.plist "$VERIFY/var/db/xbps/keys/" +cp "$KEYS/$FP.plist" "$VERIFY/var/db/xbps/keys/" +xbps-install -y -r "$VERIFY" \ + --repository="$REPO" \ + --repository=https://repo-default.voidlinux.org/current \ + -Sy voculinux +xbps-query -r "$VERIFY" -l | grep -q "^ii voculinux-" || { + echo "FAIL: voculinux not installed in the verify rootdir" >&2 + exit 1 +} +echo "verified: voculinux installs from the signed repository" + +ls "$REPO" +echo "SIGNED REPO OK: fingerprint $FP" diff --git a/packaging/void/repo.sh b/packaging/void/repo.sh new file mode 100755 index 0000000..fc4568f --- /dev/null +++ b/packaging/void/repo.sh @@ -0,0 +1,97 @@ +#!/bin/sh +# Signed xbps repository pipeline (ticket #29): build every template in +# smoke-gate order, index the full repository, and sign every binary +# package plus the repository metadata with the RSA4096 repository key. +# Runs in the pinned Void container via podman, never Docker; no host +# Void install needed. +# +# Usage: XBPS_SIGNING_KEY_FILE=/path/to/key.pem packaging/void/repo.sh +# or: XBPS_SIGNING_KEY_FILE=/path/to/key.pem just void-repo +# +# Options (all optional): +# --key-file PATH repository private key (default: $XBPS_SIGNING_KEY_FILE) +# --out-dir DIR stage the signed repo here (default: publish/xbps) +# --signed-by "N " signer identity (default: Voculinux ) +# +# The private key arrives as a file so CI can write the XBPS_SIGNING_KEY +# secret to disk with newlines intact: +# +# printf '%s' "$XBPS_SIGNING_KEY" > /tmp/xbps-sign-key.pem +# XBPS_SIGNING_KEY_FILE=/tmp/xbps-sign-key.pem packaging/void/repo.sh +# +# Fail-closed: a missing, unreadable, or non-RSA4096 key aborts before +# any container runs, so an unsigned repository can never publish. +set -eu + +CDPATH="" +HERE=$(dirname -- "$0") +HERE=$(cd -- "$HERE" && pwd) +REPO_ROOT=$(cd -- "$HERE/../.." && pwd) + +KEY_FILE=${XBPS_SIGNING_KEY_FILE:-} +OUT_DIR="$REPO_ROOT/publish/xbps" +SIGNEDBY="Voculinux " + +while [ $# -gt 0 ]; do + case "$1" in + --key-file) KEY_FILE=${2:?usage: repo.sh --key-file PATH} ; shift 2 ;; + --out-dir) OUT_DIR=${2:?usage: repo.sh --out-dir DIR} ; shift 2 ;; + --signed-by) SIGNEDBY=${2:?usage: repo.sh --signed-by "Name "} ; shift 2 ;; + -*) echo "repo.sh: unknown option: $1" >&2 ; exit 1 ;; + *) echo "repo.sh: unexpected argument: $1" >&2 ; exit 1 ;; + esac +done + +# Fail closed before any container runs: no key, no repo, no publish. +[ -n "$KEY_FILE" ] || { + echo "repo.sh: no signing key: pass --key-file or set XBPS_SIGNING_KEY_FILE" >&2 + echo "repo.sh: CI writes the XBPS_SIGNING_KEY secret to a file first:" >&2 + echo "repo.sh: printf '%s' \"\$XBPS_SIGNING_KEY\" > /tmp/xbps-sign-key.pem" >&2 + exit 1 +} +[ -r "$KEY_FILE" ] && [ -s "$KEY_FILE" ] || { + echo "repo.sh: signing key missing or empty: $KEY_FILE" >&2 + echo "repo.sh: refusing to publish an unsigned repository" >&2 + exit 1 +} +openssl rsa -in "$KEY_FILE" -check -noout >/dev/null 2>&1 || { + echo "repo.sh: not a valid RSA private key: $KEY_FILE" >&2 + echo "repo.sh: refusing to publish an unsigned repository" >&2 + exit 1 +} +openssl rsa -in "$KEY_FILE" -text -noout 2>/dev/null | head -1 | grep -q "4096 bit" || { + echo "repo.sh: signing key is not RSA4096: $KEY_FILE" >&2 + echo "repo.sh: generate one with: openssl genrsa -out xbps-sign-key.pem 4096" >&2 + exit 1 +} + +BASE_IMAGE=$(awk '$1 == "base-image" {print $3}' "$HERE/tool_checksums.txt") +VOID_COMMIT=$(awk '$1 == "void-packages" {print $3}' "$HERE/tool_checksums.txt") +[ -n "$BASE_IMAGE" ] && [ -n "$VOID_COMMIT" ] || { + echo "repo.sh: pins missing from tool_checksums.txt" >&2 + exit 1 +} +# One dependency order for the whole track: the signed repo builds the +# same set in the same order as the smoke gate (the app last), so a +# renamed or added template cannot silently drop out of either. +PKGS=$(awk -F'"' '/^PKGS=/{print $2; exit}' "$HERE/smoke.sh") +[ -n "$PKGS" ] || { + echo "repo.sh: could not read the package order from smoke.sh" >&2 + exit 1 +} + +mkdir -p "$OUT_DIR" + +echo "Pulling pinned base image: $BASE_IMAGE" +podman pull "$BASE_IMAGE" + +podman run --rm \ + -v "$REPO_ROOT/packaging/void/srcpkgs:/overlay:ro" \ + -v "$HERE/container-repo.sh:/mnt/container-repo.sh:ro" \ + -v "$KEY_FILE:/run/xbps-sign-key.pem:ro" \ + -v "$OUT_DIR:/mnt/out" \ + -e "VOID_COMMIT=$VOID_COMMIT" \ + -e "PKGS=$PKGS" \ + -e "SIGNEDBY=$SIGNEDBY" \ + "$BASE_IMAGE" /bin/sh /mnt/container-repo.sh +echo "OK: signed repository staged in $OUT_DIR" diff --git a/tests/test_void_signed_repo.py b/tests/test_void_signed_repo.py new file mode 100644 index 0000000..3735d76 --- /dev/null +++ b/tests/test_void_signed_repo.py @@ -0,0 +1,200 @@ +"""Offline policy guards for the signed Void xbps repository (ticket #29). + +Spec #16 serves the Void templates from a signed personal xbps +repository: every template builds in dependency order in the pinned Void +container via podman, the repository index carries full repo data, and +packages plus metadata are signed with the RSA4096 repository key. The +pipeline fails closed — a missing, unreadable, or non-RSA4096 key aborts +before any container runs, so an unsigned repository can never publish. + +Seams under test (public interfaces, not internals): + +- ``packaging/void/repo.sh`` — the host entry: key handling plus the + podman delegation. Exit status and stderr on a missing key are the + fail-closed contract. +- ``packaging/void/container-repo.sh`` — the container half: build-all, + index, sign, verify, stage. Its xbps-rindex invocations are the + signing contract. +- The ``void-repo`` just recipe — how release work and CI call the + pipeline, same shape as the other packaging gates. +- ``packaging/void/README.md`` — the documented maintainer and client + flow, including the CI secret name. + +Independent sources of truth: ``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 +must agree with, and ``openssl`` plus ``xbps-rindex`` own the RSA4096 +signing mechanics the container half must invoke. The container run +itself is the proof; these tests are the wiring guard. +""" + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +VOID = REPO_ROOT / "packaging" / "void" +SRC = VOID / "srcpkgs" +REPO_SH = VOID / "repo.sh" +CONTAINER_REPO = VOID / "container-repo.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 _repo_text() -> str: + assert REPO_SH.is_file(), f"missing signed-repo pipeline: {REPO_SH}" + return REPO_SH.read_text(encoding="utf-8") + + +def _container_text() -> str: + assert ( + CONTAINER_REPO.is_file() + ), f"missing container half of the signed-repo pipeline: {CONTAINER_REPO}" + return CONTAINER_REPO.read_text(encoding="utf-8") + + +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_pipeline_builds_every_template_and_nothing_else() -> None: + """A renamed or added template cannot silently drop out of the repo.""" + text = _repo_text() + assert "smoke.sh" in text or "PKGS" in text, ( + "repo.sh must take its package set from the smoke gate order, " + "not a second hand-maintained list" + ) + assert set(_smoke_packages()) == set(_srcpkg_templates()), ( + f"smoke order covers {_smoke_packages()}, " f"srcpkgs holds {_srcpkg_templates()}" + ) + + +def test_pipeline_builds_the_app_last() -> None: + """voculinux resolves the other templates from the signed repo.""" + assert ( + _smoke_packages()[-1] == "voculinux" + ), f"the app must build last, order is {_smoke_packages()}" + + +def test_pipeline_stays_podman_only_with_pinned_inputs() -> None: + """Podman, never Docker; pins live in tool_checksums.txt, not the flow.""" + for path, text in ( + (REPO_SH, _repo_text()), + (CONTAINER_REPO, _container_text()), + ): + 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 PINS.is_file(), f"missing pins file: {PINS}" + + +def test_pipeline_fails_closed_without_a_key() -> None: + """No key file means no container, no repo, no publish — never unsigned.""" + text = _repo_text() + assert KEY_FILE_VAR in text, f"repo.sh must read the key path from {KEY_FILE_VAR}" + assert ( + SIGNING_SECRET in text or "SIGNING_KEY" in text + ), "repo.sh must name the CI secret carrying the private key" + for marker in ("exit 1",): + assert marker in text, "the missing-key path must abort non-zero" + assert re.search( + r"[Nn]o .*key|missing .*key|key .*missing", text + ), "the missing-key abort must say the key is missing" + + +def test_pipeline_requires_rsa4096_before_any_container_runs() -> None: + """The contract is RSA4096: any other key aborts before podman starts.""" + text = _repo_text() + 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" + + +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 ( + re.search(r"(^|\s)-s(\s|$)", text) or "--sign " in text + ), "repository metadata signing (-s/--sign) is missing" + assert "--privkey" in text, "signing must use the provisioned private key" + assert "--signedby" in text, "signing must carry the signer identity" + + +def test_container_lints_every_template_before_building() -> None: + """One bad template fails the repo, same as the per-package harness.""" + assert ( + "xlint" in _container_text() + ), "container-repo.sh must xlint each template before building it" + + +def test_container_names_the_key_fingerprint_for_clients() -> None: + """Clients pin the key by fingerprint; the pipeline must report it.""" + assert ( + "ingerprint" in _container_text() + ), "container-repo.sh must capture the key fingerprint for release notes" + + +def test_container_ships_an_importable_client_key() -> None: + """The staged plist must parse: xbps rejects raw <> in signature-by.""" + text = _container_text() + assert ".plist" in text, "container-repo.sh must stage the client .plist" + assert "<" in text and ">" in text, ( + "the plist writer must XML-escape the signer identity " + "(xbps shows Fingerprint and refuses the import otherwise)" + ) + + +def test_container_proves_signatures_with_a_clean_rootdir_install() -> None: + """The pipeline verifies what it signed: a fresh rootdir install.""" + text = _container_text() + 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" + + +def test_just_recipe_calls_the_pipeline_like_other_packaging_gates() -> None: + """`just void-repo` runs the pipeline script with no shared-output step.""" + recipe = re.search( + r"^void-repo:([^\n]*)\n((?:[ \t].*\n|\n)+)", + JUSTFILE.read_text(encoding="utf-8"), + 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" + + +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 "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"