diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index c0e942f..c6e424f 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -12,6 +12,7 @@ on: - ".github/workflows/deploy-docs.yml" - "apps/site/**" - "packaging/repositories/apt-channel.json" + - "packaging/repositories/fedora-channel.json" - "apps/docs/**" - "assets/product-screenshot.png" - "package.json" diff --git a/.github/workflows/publish-fedora.yml b/.github/workflows/publish-fedora.yml new file mode 100644 index 0000000..2261e7f --- /dev/null +++ b/.github/workflows/publish-fedora.yml @@ -0,0 +1,83 @@ +name: Publish Fedora Repository + +on: + workflow_call: + inputs: + tag: + type: string + required: true + operation: + type: string + default: publish + workflow_dispatch: + inputs: + operation: + description: Publish a stable release, refresh expiry, or roll back to a retained revision + type: choice + options: [publish, refresh, rollback] + default: publish + tag: + description: Existing stable release tag for publish + type: string + revision: + description: Retained repository revision SHA-256 for rollback + type: string + schedule: + - cron: "53 5 * * 1" + +permissions: + contents: read + +concurrency: + group: fedora-repository-production + cancel-in-progress: false + +jobs: + publish: + if: >- + ${{ + vars.FEDORA_REPOSITORY_ENABLED == 'true' && + (github.ref == format('refs/heads/{0}', github.event.repository.default_branch) || + (github.workflow == 'Release' && startsWith(github.ref, 'refs/tags/v'))) + }} + runs-on: ubuntu-24.04 + timeout-minutes: 35 + environment: packages-production + container: + image: fedora:44@sha256:be9d65e2344d805cc11114319c685ecaa96b6d9b4350a0a6460cdb931babbd19 + steps: + - name: Install repository and workflow tools + run: >- + dnf install -y + createrepo_c dnf git gh gnupg2 nodejs openssh-clients openssl python3 rpm-build rpm-sign + + - name: Checkout publisher + uses: actions/checkout@v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Build, publish, and verify repository + env: + GH_TOKEN: ${{ github.token }} + OPERATION: ${{ inputs.operation || 'refresh' }} + RELEASE_TAG: ${{ inputs.tag }} + ROLLBACK_REVISION: ${{ inputs.revision }} + FEDORA_REPOSITORY_URL: ${{ vars.FEDORA_REPOSITORY_URL }} + FEDORA_REPOSITORY_HOST: ${{ vars.FEDORA_REPOSITORY_HOST }} + FEDORA_REPOSITORY_ROOT: ${{ vars.FEDORA_REPOSITORY_ROOT }} + FEDORA_SSH_PORT: ${{ vars.FEDORA_SSH_PORT || '22' }} + FEDORA_SIGNING_FINGERPRINT: ${{ vars.FEDORA_SIGNING_FINGERPRINT }} + FEDORA_SSH_PRIVATE_KEY: ${{ secrets.FEDORA_SSH_PRIVATE_KEY }} + FEDORA_SSH_KNOWN_HOSTS: ${{ secrets.FEDORA_SSH_KNOWN_HOSTS }} + FEDORA_SIGNING_KEY: ${{ secrets.FEDORA_SIGNING_KEY }} + FEDORA_SIGNING_PASSPHRASE: ${{ secrets.FEDORA_SIGNING_PASSPHRASE }} + run: bash scripts/publish-fedora-workflow.sh + + - name: Upload public verification and activation record + uses: actions/upload-artifact@v7.0.1 + with: + name: loopwire-fedora-publication-${{ github.run_id }} + path: dist/fedora-publication + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fd12162..b6d8443 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -513,3 +513,13 @@ jobs: tag: ${{ needs.publish-release.outputs.tag }} operation: publish secrets: inherit + + publish-fedora: + name: Publish signed Fedora channel + needs: publish-release + if: ${{ vars.FEDORA_REPOSITORY_ENABLED == 'true' && !contains(needs.publish-release.outputs.tag, '-') }} + uses: ./.github/workflows/publish-fedora.yml + with: + tag: ${{ needs.publish-release.outputs.tag }} + operation: publish + secrets: inherit diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 8e80ef8..3937adf 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -6,6 +6,7 @@ on: - ".github/workflows/web.yml" - "apps/site/**" - "packaging/repositories/apt-channel.json" + - "packaging/repositories/fedora-channel.json" - "apps/docs/**" - "assets/product-screenshot.png" - "README.md" @@ -32,6 +33,7 @@ on: - ".github/workflows/web.yml" - "apps/site/**" - "packaging/repositories/apt-channel.json" + - "packaging/repositories/fedora-channel.json" - "apps/docs/**" - "assets/product-screenshot.png" - "README.md" diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml index a6b5667..5d34f1b 100644 --- a/.github/workflows/workflow-checks.yml +++ b/.github/workflows/workflow-checks.yml @@ -8,6 +8,7 @@ on: - "scripts/test-ci-impact.rb" - "scripts/test-ci-workflow-paths.rb" - "scripts/test-apt-workflow.rb" + - "scripts/test-fedora-workflow.rb" - "scripts/*native-package-proof-snapshot.mjs" - "scripts/verify-github-workflows.sh" - "scripts/verify-requirements.sh" @@ -23,6 +24,7 @@ on: - "scripts/test-ci-impact.rb" - "scripts/test-ci-workflow-paths.rb" - "scripts/test-apt-workflow.rb" + - "scripts/test-fedora-workflow.rb" - "scripts/*native-package-proof-snapshot.mjs" - "scripts/verify-github-workflows.sh" - "scripts/verify-requirements.sh" diff --git a/.planning/STATE.md b/.planning/STATE.md index 5466819..9edbec6 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,7 +3,7 @@ gsd_state_version: 1.0 milestone: v0.5 milestone_name: GitHub Operator Setup status: Ready for Review -last_updated: "2026-09-05T14:08:23Z" +last_updated: "2026-09-05T15:03:31Z" last_activity: 2026-09-05 progress: total_phases: 1 @@ -27,7 +27,7 @@ See: .planning/PROJECT.md (updated 2026-07-03) Phase: 19 of 19 complete Plan: 19.1 — Hardened GitHub Actions Setup Status: Ready for review in PR #9 -Last activity: 2026-09-05 - completed quick task 260905-kyo: signed APT repository development and clean-guest proof +Last activity: 2026-09-05 - completed quick task 260905-mhp: signed Fedora repository development and clean-guest proof ## Blockers / Concerns @@ -94,6 +94,7 @@ Last activity: 2026-09-05 - completed quick task 260905-kyo: signed APT reposito | 260905-hia | Minimal landing identity, GSAP reactions and screenshot proofs in PR #40 | 2026-09-05 | 6e84a9a | [260905-hia-landing-identity](./quick/260905-hia-landing-identity/) | | 260905-i4l | Scope CI to affected files while retaining release validation | 2026-09-05 | f9bc0d8 | [260905-i4l-scope-ci](./quick/260905-i4l-scope-ci/) | | 260905-kyo | Signed APT repository development and clean-guest lifecycle proof | 2026-09-05 | 639cbbb | [260905-kyo-signed-apt](./quick/260905-kyo-signed-apt/) | +| 260905-mhp | Signed Fedora repository development and clean-guest lifecycle proof | 2026-09-05 | e73c5bb | [260905-mhp-signed-fedora](./quick/260905-mhp-signed-fedora/) | ## Accumulated Context diff --git a/.planning/quick/260905-mhp-signed-fedora/260905-mhp-PLAN.md b/.planning/quick/260905-mhp-signed-fedora/260905-mhp-PLAN.md new file mode 100644 index 0000000..0487614 --- /dev/null +++ b/.planning/quick/260905-mhp-signed-fedora/260905-mhp-PLAN.md @@ -0,0 +1,57 @@ +--- +status: implementing +issue: 36 +depends_on: 45 +--- + +# Signed Fedora repository development + +Goal: complete every development task in #36 and open a dedicated PR containing `resolves #36`. This branch is an +intentional stack on #35/PR #45 because the Fedora channel reuses its reviewed SSH/POSIX publication, protected +environment, public activation, and guest-proof foundations. Production provider ownership, credentials, signing +identity and first public activation remain the separately listed human operational tasks. + +## Decisions + +- Choose a project-owned repository over COPR. It indexes the exact OpenSSL-authenticated release RPM, controls when + the RPM is signed and verified, supports reviewed revision/CAS/retention/recovery semantics, and can be tested + locally and over the same restricted SSH origin. COPR rebuilds from source and owns signing/publication timing, so + its output would need distinct provider build evidence and would not be the existing release artifact. +- Initial scope is Fedora 44 x86_64 only. #37 adds openSUSE independently after this PR is open. +- The repository-distributed copy of the GitHub Release RPM receives a separate OpenPGP RPM signature before its + final repository hash and lifecycle evidence are recorded. The source release hash and distributed hash stay + distinct and traceable. +- Require both `gpgcheck=1` for package signatures and `repo_gpgcheck=1` for the detached OpenPGP signature over + `repodata/repomd.xml`. No local-RPM signature exception applies to the repository path. +- Retain immutable signed RPMs and content-addressed repodata indefinitely in v1. Publication must serialize writers, + require an expected revision, reject immutable collisions, recover interruption, and publish metadata only after + every package/content object exists. The implementation must make clients either verify a complete revision or + fail safely during the promotion boundary; exact commit semantics are recorded after generator/publisher tests. +- Metadata expires after 30 days and gets protected weekly refresh. Rollback selects a retained package set, signs + fresh metadata, and documents the explicit DNF downgrade needed on already-upgraded clients. +- The checked-in Fedora channel remains pending. Existing signed direct-download and automatic-installer paths stay + functional until a human reviews the production public proof record and commits activation data. + +## Work lanes + +1. `fedora_repo_protocol`: exact release authentication, RPM/repository signing, createrepo metadata, manifest, + retention/rollback and real DNF/tamper tests in a pinned Fedora toolchain. +2. `fedora_publisher`: local/SSH POSIX publication, commit semantics, CAS/locks/recovery, immutable retention, actual + SSH and HTTP/cache tests. +3. `fedora_guest`: isolated clean Fedora 44 KVM lifecycle and strict raw evidence verifier, including package + signatures, installed payload hashes, providers and a real GUI window. +4. `fedora_docs`: provider comparison, pending/verified website gate and complete user/operator documentation. +5. Root integration: scoped setup/public verification, protected release/refresh/rollback workflow, configuration + contracts, CI gates, browser fixtures, final review, issue checklist and PR delivery. + +## Required verification + +- Real DNF with repository and package signature checks accepts correct content and rejects wrong/unsigned/tampered + RPMs and metadata. Version/architecture/target and downgrade rules are independently verified. +- Publication proves writer exclusion, CAS, ordering/commit behavior, idempotence, interruption recovery, permissions, + immutable retention, rollback, actual SSH transport, and public cache behavior without production credentials. +- A clean checksum-pinned Fedora 44 KVM guest performs repository install, reinstall, fixture upgrade, explicit + rollback/downgrade, removal and repository removal against the final committed development code. Raw proof binds + source/distributed hashes, signer, origin, versions, installed bytes, providers and GUI behavior. +- Pending and verified-fixture browser states, workflow/action syntax, documentation, focused tests and full project + gates pass. Public production remains disabled and no human task is reported complete without its real evidence. diff --git a/.planning/quick/260905-mhp-signed-fedora/260905-mhp-SUMMARY.md b/.planning/quick/260905-mhp-signed-fedora/260905-mhp-SUMMARY.md new file mode 100644 index 0000000..e59efd4 --- /dev/null +++ b/.planning/quick/260905-mhp-signed-fedora/260905-mhp-SUMMARY.md @@ -0,0 +1,88 @@ +--- +status: complete +issue: 36 +depends_on: 45 +--- + +# Signed Fedora repository development + +Issue: https://github.com/sandwichfarm/loopwire/issues/36 + +## Result and stack + +The Fedora 44 x86_64 development work is complete. This branch intentionally stacks on #35/PR #45 to reuse its +reviewed SSH/POSIX publication, protected environment, activation gate, and KVM harness. The dedicated Fedora channel +record remains `pending`; the separate Human operational tasks still own production hosting, signing identity, +GitHub configuration, first public publication, and website activation. Until then the existing signed direct RPM and +automatic installer remain visible. + +## Development checklist evidence + +1. **Provider evaluation:** the maintainer runbook compares COPR and project-owned delivery across output provenance, + signing, Fedora targeting, promotion, proof, retention, and rollback. Project-owned was selected because it consumes + the exact project-authenticated release RPM, signs only a staged copy, controls publication and produces local/CI + proof. COPR output would be a separate provider build needing provider-specific evidence. +2. **Package path:** the generator accepts only Fedora 44 x86_64 `loopwire-VERSION-1.fc44.x86_64.rpm`, while allowing + the known signed openSUSE sibling in the real GitHub Release. It verifies the OpenSSL release signature, signed + checksum, RPM digest, NEVRA and public release manifest before repository processing. Source release and distributed + hashes are recorded separately; the source GitHub RPM is never mutated. +3. **Signing:** `rpmsign` applies an RSA/SHA-256 OpenPGP package signature to the staged repository copy. + `repodata/repomd.xml.asc` separately authenticates SHA-256 metadata objects. Both are verified using isolated RPM + and GnuPG databases pinned to the expected primary fingerprint. Passphrases travel only through protected files. +4. **Publication/rollback:** the Fedora publisher retains RPMs, fingerprint keys, checksum-named repodata, and private + snapshots indefinitely in v1. It validates before writes, locks the origin, requires revision CAS, rejects + immutable collisions, writes the new signature then atomically commits `repomd.xml`, journals every checkpoint, + and recovers interrupted or explicitly reviewed expired transactions. Real DNF fails closed during the brief mixed + signature/XML state and succeeds after recovery. Rollback freshly signs the retained package set. +5. **Protected automation:** Publish Fedora Repository uses a checksum-pinned Fedora 44 container and supports a + release call, manual publish/refresh/rollback, and weekly refresh. `FEDORA_REPOSITORY_ENABLED=true` plus the + `packages-production` environment gates all writes. Stable publication waits for the existing GitHub Release and + evidence gates, re-downloads public assets, publishes over pinned SSH, and verifies every HTTPS-served byte before + producing a reviewable activation record. +6. **Bootstrap:** the repeat-safe helper targets Fedora 44 x86_64, verifies the HTTPS key's full fingerprint, writes + only the managed `.repo` and fingerprint key file, and requires `gpgcheck=1`, `repo_gpgcheck=1`, `sslverify=1`, + `skip_if_unavailable=False`. Dry-run has no network/writes; removal preserves other repositories, installed packages + and RPM-database trust. Docs explain inspecting/removing previously accepted RPM keys during rotation or compromise. +7. **Regression tests:** real DNF accepts only valid package and repodata signatures and rejects wrong signers, + unsigned/tampered RPMs and changed metadata. Tests cover name/version/release/architecture, real release sibling + assets, version order, retention, fresh rollback, deterministic fixed-date candidates, encrypted keys, unsafe paths, + concurrent locks, CAS, every interruption checkpoint, permissions, actual SSH without remote GPG, Nginx cache/404 + headers and real DNF recovery from a mismatched signature/XML transition. +8. **Clean Fedora lifecycle:** a checksum-pinned Fedora 44 KVM guest consumes the actual public v0.1.0 Fedora RPM, + authenticated through the project release key, SHA256SUMS, release-assets manifest, public release commit and tar + RELEASE metadata. The repository-signed baseline is installed/reinstalled through DNF, upgraded to the explicitly + synthetic `+dnffixture1`, downgraded to the public baseline, removed, and its repository removed. Proof binds DNF + origin, embedded signatures, source/distributed hashes, installed `/usr` bytes, providers, backend JSON, GUI linkage + and a real X11 window. Fixture keys use an isolated RPM database that is removed on every exit. +9. **Docs/UI:** Fedora homepage, install guide, support matrix, release guide, user guide, operator runbook, packaging + docs, navigation and release notes are updated. Pending preserves the existing authenticated local-RPM path; + verified-fixture browser proof switches only Fedora to `sudo dnf install loopwire` and its separate setup link. + DNF's custom-deadline replay limitation is explicit. Production activation remains human-owned and the repository + is never described as a default Fedora repository. + +## Verification + +- Final `pnpm check` passed: project verification, types, 295 workspace tests, 22 Rust tests, native/package gates, + production builds, static-site verification, and both APT/Fedora repository suites. +- `pnpm verify:rpm-repository` passed in the pinned Fedora 44 image: 13 generator, 20 publisher, 7 bootstrap, + 6 public HTTPS, 3 workflow preflight, 34 raw proof-verifier and 4 channel-gate cases. +- The publisher suite exercised actual SSH and DNF5. DNF rejected the intentionally interrupted signature/XML pair; + recovery restored a valid repository. Nginx syntax and live cache/404 headers passed. +- The Fedora 44 KVM lifecycle produced 122 evidence files at `e73c5bb` and passed the independent verifier. Baseline + source SHA-256 is `5a163db0acd1d2f8c73f8cff1b4cc05f12a3d811bfedaf681ea46f460d4d1fb3`, matching the public release manifest. +- Pending and activated-fixture Chromium runs passed the nine platform panels, commands, keyboard/copy failure and + recovery, no-JavaScript fallback, responsive widths, signal motion/reduced-motion/visibility checks, and Fedora + setup link/guide command. The checked-in channel was restored to pending. +- Workflow contracts, actionlint, ShellCheck, Python/Node/Ruby/Bash syntax, docs/build checks and whitespace passed. + A comprehensive review's public-release provenance finding was fixed and approved on targeted re-review. + +## Boundaries + +The issue's five Human operational tasks remain unchecked. No production host, TLS/DNS, account, OpenPGP identity, +SSH credential, GitHub environment value, repository publication, or website activation was created or changed. +The project verifier enforces the custom signed metadata deadline; DNF itself verifies signatures but does not enforce +that project-specific expiry tag, so HTTPS/origin control and monitoring remain part of operations. + +Power-loss and network filesystems were not exercised; production requires local POSIX flock/fsync/atomic-rename +semantics. The KVM repository signer, TLS CA and upgrade version are disposable fixture material. Only the baseline +source RPM is the actual published v0.1.0 Fedora artifact. diff --git a/apps/docs/docs/.vitepress/config.ts b/apps/docs/docs/.vitepress/config.ts index afd70bc..4ca04f9 100644 --- a/apps/docs/docs/.vitepress/config.ts +++ b/apps/docs/docs/.vitepress/config.ts @@ -44,6 +44,7 @@ export default defineConfig({ items: [ { text: "Install", link: "/guide/install" }, { text: "APT Repository", link: "/guide/apt-repository" }, + { text: "Fedora Repository", link: "/guide/fedora-repository" }, { text: "Basic Usage", link: "/guide/basic-usage" }, { text: "Configurations", link: "/guide/configurations" }, { text: "Audio Backends", link: "/guide/backends" }, @@ -62,6 +63,7 @@ export default defineConfig({ { text: "VM Matrix", link: "/developer/vm-matrix" }, { text: "Release", link: "/developer/release" }, { text: "APT Repository Operations", link: "/developer/apt-repository" }, + { text: "Fedora Repository Operations", link: "/developer/fedora-repository" }, { text: "Release Notes", link: "/developer/release-notes" } ] }, diff --git a/apps/docs/docs/developer/fedora-repository.md b/apps/docs/docs/developer/fedora-repository.md new file mode 100644 index 0000000..33b8589 --- /dev/null +++ b/apps/docs/docs/developer/fedora-repository.md @@ -0,0 +1,277 @@ +# Signed Fedora repository operations + +This runbook covers development, publication, and recovery for Loopwire's third-party Fedora channel. Tooling can be +tested without production access, but the checked-in channel record starts `pending`. Provisioning the origin and +signing identity, configuring the protected GitHub environment, completing the first public publication, and +reviewing its clean-client proof remain human operations. Keep the signed direct-download path available until those +steps are complete. + +## Scope and provider decision + +Version 1 serves one target: **Fedora 44, x86_64**, at a canonical HTTPS base such as +`https://HOST/fedora/44/x86_64`. It uses the existing Fedora package recipe and authenticated GitHub Release input; +no UI or audio-backend behavior belongs in this layer. + +The project chose a project-owned repository after comparing it with COPR: + +| Requirement | COPR | Project-owned repository | +| --- | --- | --- | +| Release artifact control | COPR builds provider output from submitted inputs; its RPM would need separate build-ID and exact-output proof. | The generator verifies the existing signed release manifest and exact Fedora RPM, then signs a staged copy without changing the GitHub Release. | +| Signing | Provider-managed package signing reduces private-key custody, but signed-metadata and stable-promotion behavior remain provider policy. | One dedicated project OpenPGP identity signs the distributed RPM and repository metadata under the protected environment. | +| Fedora target | COPR chroots can target supported Fedora releases, subject to service lifecycle. | The generator rejects every target except the tested Fedora 44 x86_64 contract. | +| Promotion and proof | Async build completion and repository visibility need provider-specific polling and build records. | A complete candidate is verified locally, published with compare-and-swap protection, and checked byte-for-byte over public HTTPS. | +| Retention and rollback | Build retention and project state depend on provider policy and configuration. | Immutable packages, keys, content metadata, and signed snapshots remain under project control; rollback republishes a retained package set with fresh metadata. | + +Project-owned hosting gives release artifact control, atomic promotion, explicit retention, and the same local/CI proof +surface as the existing release flow. It also keeps Fedora-specific proof independent from any future openSUSE channel. +The tradeoff is operational responsibility for the HTTPS origin, SSH access, OpenPGP recovery, caching, monitoring, +and storage growth. + +## Trust and repository layout + +The generator verifies the existing OpenSSL signature on `SHA256SUMS` and the input RPM checksum before staging it. +It then signs or re-signs only the staged RPM with the dedicated repository OpenPGP key. The GitHub Release artifact +is never rewritten. DNF validates two related signatures: + +1. `gpgcheck=1` validates the distributed RPM's embedded OpenPGP signature. +2. `repo_gpgcheck=1` validates `repodata/repomd.xml.asc`, which authenticates checksums for the retained metadata and + package index. + +The public target root contains: + +```text +fedora/44/x86_64/ +├── keys/FINGERPRINT.asc +├── packages/loopwire-VERSION-1.fc44.x86_64.rpm +├── repodata/repomd.xml +├── repodata/repomd.xml.asc +├── repodata/CHECKSUM-primary.xml.gz +└── repository-manifest.json +``` + +The manifest uses `loopwire.rpm-repository.v1` with schema version 1 and target +`{"distribution":"fedora","release":"44","architecture":"x86_64"}`. Its revision is the lowercase SHA-256 of the +canonical JSON excluding the `revision` field. It records the signing fingerprint, creation time, project verification +deadline, source/distributed RPM hashes, packages, and every public file. Do not hand-edit generated repository state. + +## Human provisioning + +Provision a project-owned HTTPS host with a valid public certificate and an SSH account restricted to the private +repository root. The host needs Python 3 and POSIX filesystem semantics. Put transaction staging and the public tree +on the same filesystem so `repomd.xml` replacement is atomic. Serve **`ROOT/public` only**; keep `ROOT/snapshots`, +`ROOT/state`, locks, incoming transactions, SSH identities, and private signing material outside the web root. Back up +private snapshots and state independently. + +The project-owned SSH/POSIX origin is required because the existing website upload surface does not provide the +compare-and-swap, retention, or atomic metadata-pointer contract. The ordinary website deployment remains separate. +Configure caching by object role: + +- `repodata/repomd.xml`, its detached signature, `repository-manifest.json`, and all HTTP 404 responses: `no-store` or + mandatory revalidation. +- RPMs, public key files, and checksum-named metadata objects: a one-year immutable policy. +- Never cache an absent mutable object that a publication or recovery operation will create. + +Create a dedicated OpenPGP identity offline. Record the full 40-character uppercase fingerprint, expiration, +custodian, encrypted backup, and revocation-certificate location. Keep recovery material outside CI. Do not reuse the +OpenSSL release key or export the private repository key to the origin. + +Configure the GitHub environment **`packages-production`**, restrict it to reviewed release/default-branch inputs, +and apply its human approval policy. `.github/workflows/publish-fedora.yml` validates these settings before remote +writes: + +| Kind | Name | Purpose | +| --- | --- | --- | +| Repository variable | `FEDORA_REPOSITORY_ENABLED` | Set to `true` only after provisioning to permit protected publication. | +| Variable | `FEDORA_REPOSITORY_URL` | Exact public HTTPS target URL ending in `/fedora/44/x86_64`, without credentials, query, or fragment. | +| Variable | `FEDORA_REPOSITORY_HOST` | Restricted SSH `USER@HOST` used by the publisher. | +| Variable | `FEDORA_REPOSITORY_ROOT` | Absolute private origin root; HTTP maps its `public` child as the document root. | +| Variable | `FEDORA_SIGNING_FINGERPRINT` | Complete 40-character uppercase OpenPGP fingerprint. | +| Optional variable | `FEDORA_SSH_PORT` | SSH port when it is not 22. | +| Secret | `FEDORA_SSH_PRIVATE_KEY` | Restricted SSH identity for publication. | +| Secret | `FEDORA_SSH_KNOWN_HOSTS` | Host-key pins obtained through a trusted channel. | +| Secret | `FEDORA_SIGNING_KEY` | Dedicated ASCII-armored OpenPGP private signing key. | +| Optional secret | `FEDORA_SIGNING_PASSPHRASE` | Passphrase for that private export. | + +Leave the enable variable false until every production value, secret, and protection rule is ready. Enabling +publication permits protected repository writes; it does not activate the website's short DNF command. Do not +generate SSH host pins from an unauthenticated scan inside the publishing job. Monitor failed publications, origin +drift, TLS expiry, signing-key expiry, the project metadata verification deadline, and storage growth. + +## Build and verify a candidate + +Use a reviewed checkout plus a directory containing the published Fedora RPM, `SHA256SUMS`, and `SHA256SUMS.sig`. +`RPM_FPR` is the complete OpenPGP fingerprint and `RPM_GNUPG_HOME` is a protected GnuPG home containing its private +key. Local generation needs Python 3, RPM tools including `rpmsign`, `createrepo_c`, GnuPG, and OpenSSL. + +```bash +python3 scripts/rpm-repository.py build \ + --release-dir dist/release --version 0.1.0 --output dist/rpm-repository \ + --signing-key "$RPM_FPR" --gnupg-home "$RPM_GNUPG_HOME" \ + --release-public-key packaging/release-signing-public.pem +python3 scripts/rpm-repository.py verify \ + --repository dist/rpm-repository \ + --public-key rpm-public.asc --fingerprint "$RPM_FPR" +``` + +The verifier rejects a missing or invalid RPM signature, wrong signer, changed RPM bytes, unsigned or changed +metadata, unexpected target/version/architecture, unlisted files, and a passed project verification deadline. +Production uses stable `X.Y.Z` versions and the project release trust anchor. `--release-public-key FILE` exists for +explicit fixture trust anchors; do not replace the production anchor with fixture material. + +Use `--previous DIR` to retain objects and older packages from the current repository. `--passphrase-file FILE` +supports a protected signing-key passphrase. `--date EPOCH` is for deterministic fixtures; production uses current +time. The custom signed `loopwire-valid-until` deadline is 30 days by default, and `--valid-for-days` accepts only 1 +through 90. Loopwire's verifier and publishing workflow enforce it; DNF does not understand this project tag or +provide APT-style signed `Valid-Until` enforcement. The public monitor must not rely on scheduled GitHub runs happening +exactly on time. DNF's client `metadata_expire=6h` setting only controls cache refresh frequency. + +An explicit `--date` fixes createrepo timestamps plus RPM and metadata signature creation times. Byte-identical output +also depends on the pinned Fedora tool versions, identical key material, and a deterministic OpenPGP algorithm. When +`--previous` already contains the same version with the same authenticated source-release hash, the generator reuses +that signed RPM instead of manufacturing different signed bytes for an unchanged release input. + +## Publish, fetch, and recover + +Use the protected **Publish Fedora Repository** workflow for production publication, refresh, or rollback. Tagged +release integration must run only after GitHub Release publication succeeds and `FEDORA_REPOSITORY_ENABLED=true`. +The workflow downloads and verifies published release inputs rather than trusting an arbitrary runner directory. A +Fedora publication failure leaves the GitHub Release and previous repository revision intact; repair the failure and +retry this channel. Its weekly schedule refreshes signed metadata without inventing a new application release; delayed +or disabled schedules still require external expiry monitoring. + +The publisher supports local rehearsal and SSH operation. For production, provide all SSH identity and host-key +arguments. Here `RPM_ROOT`, `RPM_HOST`, and `RPM_REVISION` refer to the provisioned private root, restricted SSH host, +and currently verified manifest revision. Use `empty` only for the first publication. + +```bash +python3 scripts/publish-rpm-repository.py fetch \ + --root "$RPM_ROOT" --output dist/rpm-previous \ + --public-key rpm-public.asc --fingerprint "$RPM_FPR" \ + --ssh "$RPM_HOST" --identity-file rpm-ssh-key --known-hosts rpm-known-hosts +python3 scripts/publish-rpm-repository.py publish \ + --repository dist/rpm-repository --root "$RPM_ROOT" \ + --public-key rpm-public.asc --fingerprint "$RPM_FPR" --expected-revision "$RPM_REVISION" \ + --ssh "$RPM_HOST" --identity-file rpm-ssh-key --known-hosts rpm-known-hosts --dry-run +``` + +Read the dry-run result, then repeat without `--dry-run`. Add `--ssh-port PORT` when required. Take the current +revision from verified `fetch` output. A conflicting writer must fetch and rebuild against the latest revision; do not +force stale state over it. Server locking serializes publication and the operation is repeat-safe for an already +committed revision. + +The publisher installs immutable RPM, key, and checksum-named metadata objects first. Existing immutable paths with +different bytes are rejected. It writes `repodata/repomd.xml.asc` before atomically replacing +`repodata/repomd.xml`, which is the metadata commit point. A client in the brief interval between those two writes may +see a signature/metadata mismatch and fail closed; it never accepts unauthenticated metadata. Retry after publication +completes. Fedora 44 DNF5 may report the bad signature, exclude the repository, and still return exit code 0 from +`dnf repoquery`; lifecycle and monitoring checks must reject the verification diagnostic and require the expected +Loopwire package result instead of trusting process status alone. A future protocol would need a versioned target URL +to eliminate even that fail-closed window. + +An interrupted promotion preserves a recovery journal. Inspect and resume the exact pending revision: + +```bash +python3 scripts/publish-rpm-repository.py recover \ + --root "$RPM_ROOT" --public-key rpm-public.asc --fingerprint "$RPM_FPR" \ + --ssh "$RPM_HOST" --identity-file rpm-ssh-key --known-hosts rpm-known-hosts --dry-run +``` + +Repeat without `--dry-run` after review. `--allow-expired` is limited to an operator-reviewed journal whose project +verification deadline passed: it can finish only that authenticated transition, then requires an immediate fetch, +fresh re-sign, and publication. It does not disable project verification for ordinary candidates. DNF signature checks +alone may accept replayed older correctly signed metadata, which is why immediate refresh plus HTTPS/origin monitoring +remain required. Never edit public metadata, snapshots, or state by hand to bypass a conflict. + +## Retention and rollback + +Version 1 retains RPMs, key files, checksum-named metadata, and signed snapshots indefinitely. There is no automatic +garbage collection. Monitor storage growth; any deletion policy needs a separate design covering cached clients, +manifest references, rollback availability, and incident evidence. + +Fetch a retained revision with `fetch --revision SHA`. Project policy requires rollback to generate fresh signed +metadata for that known-good package set. Copying an old `repomd.xml` whose project verification deadline passed would +be replay, even though DNF may still accept its valid signature: + +```bash +python3 scripts/rpm-repository.py rollback \ + --repository dist/rpm-known-good --output dist/rpm-rollback \ + --signing-key "$RPM_FPR" --gnupg-home "$RPM_GNUPG_HOME" +python3 scripts/rpm-repository.py verify \ + --repository dist/rpm-rollback --public-key rpm-public.asc --fingerprint "$RPM_FPR" +``` + +Publish the rollback candidate against the current revision and repeat public verification. Installed newer packages +do not downgrade automatically. Communicate either `sudo dnf downgrade loopwire` or the exact retained +`sudo dnf install loopwire-VERSION-RELEASE.x86_64` command; the +[user guide](../guide/fedora-repository.md#earlier-versions) +explains both choices. + +## Key rotation and revocation + +Treat routine key rotation as a coordinated release. Generate the successor offline, announce its complete +fingerprint through trusted channels, and test clean-client setup, existing-client migration, upgrade, reinstall, and +rollback. The conservative path uses a new HTTPS repository prefix signed only by the successor. Existing clients +rerun the setup helper with the reviewed new URL and fingerprint; package upgrades do not silently grant trust to a +new key. Keep the old prefix available during an announced migration window only while its key remains trustworthy. + +The fingerprint-named public key object is immutable. Do not overwrite it after an expiration/subkey change. A +fetched old snapshot still requires its original key, and re-signing unverified historical bytes does not establish +their provenance. + +For compromise, disable publication immediately, remove the private export from CI, publish the revocation and +incident notice through trusted channels, and mark `packaging/repositories/fedora-channel.json` pending. Existing +clients must remove the old repository/key file and bootstrap the reviewed replacement explicitly. Preserve evidence +and recover only from authenticated release inputs or known-good snapshots. + +## Public verification and final activation + +After publication, verify every production HTTPS byte against the signed local candidate: + +```bash +python3 scripts/verify-rpm-public.py \ + --repository dist/rpm-repository --public-key rpm-public.asc --fingerprint "$RPM_FPR" \ + --base-url "$FEDORA_URL" --proof-url "$FEDORA_PROOF_URL" --output dist/fedora-channel.json +``` + +The checker validates the local trust chain, fetches the production repository over HTTPS without redirects, compares +exact hashes and sizes, and emits a verified record only on success. A fixture CA or synthetic upgrade is development +evidence, never production proof. + +The workflow artifact `loopwire-fedora-publication-RUN_ID` contains `fedora-channel.json`, `publication.json`, and +`repository-manifest.json`. **Final activation is a human operation:** review the successful protected run, public +URL, target, signing fingerprint, revision, timestamp, and lifecycle evidence. Then copy the emitted channel record to +`packaging/repositories/fedora-channel.json` in a reviewed commit and deploy the website from that commit. Do not edit +`status` alone or place credentials in the channel file. + +A verified version 1 record requires `target: "fedora-44"`, an HTTPS base URL, a 40-character uppercase fingerprint, +a 64-character lowercase revision, an ISO timestamp, and a project GitHub Actions run URL. Missing or malformed data +keeps the homepage on the signed direct-download command. A valid record changes only the Fedora tab to +`sudo dnf install loopwire` and a separate one-time setup link. Automatic installation and other platform options +remain available. + +## Development and guest evidence + +Run the generator, publisher, bootstrap, public-checker, channel-gate, workflow, documentation, and site tests. The +pinned Fedora tools image gives non-Fedora hosts the RPM toolchain without changing host package state: + +```bash +bash scripts/with-rpm-tools.sh --container python3 scripts/test-rpm-repository.py +python3 scripts/test-fedora-bootstrap.py +python3 scripts/test-publish-rpm-repository.py +python3 scripts/test-rpm-public.py +node --test apps/site/src/lib/rpmChannel.test.mjs +``` + +Use the dedicated Fedora 44 guest lifecycle modes for stronger evidence: + +```bash +bash scripts/native-package-vm.sh run-fedora-repo \ + --target fedora-44 --version 0.1.0 --release-dir dist/release +bash scripts/native-package-vm.sh verify-fedora-repo --target fedora-44 +``` + +The clean, checksum-pinned guest must exercise repository setup, install, reinstall, synthetic upgrade, explicit +downgrade/rollback, removal, and repository removal. Record the target, repository origin, exact versions, package and +metadata signer, public URL, signature results, GUI/provider smokes, and command logs. Synthetic fixture releases +exercise lifecycle behavior with authenticated existing payloads; they do not create a public application release, +prove production reachability, or promote Fedora desktop/audio support. diff --git a/apps/docs/docs/developer/release.md b/apps/docs/docs/developer/release.md index dbbba88..eec7a4f 100644 --- a/apps/docs/docs/developer/release.md +++ b/apps/docs/docs/developer/release.md @@ -17,6 +17,24 @@ verified record is committed and the site deployed, Ubuntu and Debian homepage t commands. Fixture lifecycle tests do not count as production proof. Follow the runbook's final activation procedure before announcing repository availability. +## Signed Fedora channel + +Fedora 44 x86_64 publication has a separate +[Fedora repository operations runbook](./fedora-repository.md). The selected provider is a project-owned HTTPS +repository: this keeps control of the exact release artifact, RPM and metadata signing, atomic promotion, indefinite +retention, and rollback proof. COPR would produce provider-built RPMs that need a separate build-ID and +exact-provider-output proof path, while its retention and promotion behavior remain service-owned. + +The optional **Publish Fedora Repository** workflow runs after GitHub Release publication only when +`FEDORA_REPOSITORY_ENABLED=true` in the protected `packages-production` environment. A repository failure leaves the +published GitHub Release and previous Fedora revision intact for repair and retry. Production origin/signing +provisioning and first activation remain human tasks. + +The Fedora homepage tab remains on its signed direct-download RPM while +`packaging/repositories/fedora-channel.json` is pending. Only a complete public HTTPS verification record changes that +tab to `sudo dnf install loopwire` and links the separate one-time setup procedure. The automatic installer continues +to work through the direct-download path, and fixture lifecycle evidence cannot activate the production channel. + ## Local Artifact Smoke ```bash diff --git a/apps/docs/docs/guide/fedora-repository.md b/apps/docs/docs/guide/fedora-repository.md new file mode 100644 index 0000000..a93c0b9 --- /dev/null +++ b/apps/docs/docs/guide/fedora-repository.md @@ -0,0 +1,150 @@ + + +# Fedora repository + +Loopwire's third-party Fedora repository targets **Fedora 44 on x86_64**. It requires one-time setup because Loopwire +is not included in Fedora's default repositories. Other Fedora releases and architectures should use the matching +option in the [installation guide](./install.md). + +
+

Public channel pending

+

The repository implementation is available in the source tree, but its public URL and signing key have not been + activated. Use the signed Fedora direct download or the automatic installer. + The setup command will appear here only after the production repository passes public verification.

+
+ +
+

Verified public channel

+

Repository: {{ channel.baseUrl }}
+ OpenPGP fingerprint: {{ channel.signingFingerprint }}
+ Public verification record, recorded {{ channel.verifiedAt }}.

+
+ +## One-time setup + +The following procedure applies once this page displays a verified public channel. You need Bash, curl, GnuPG, RPM, +DNF, and sudo access. Download and inspect the small setup helper first: + +```bash +curl -fsSLo setup-fedora-repository.sh \ + https://raw.githubusercontent.com/sandwichfarm/loopwire/master/scripts/setup-fedora-repository.sh +less setup-fedora-repository.sh +``` + +
+

Run the helper with the published URL and complete signing fingerprint:

+
{{ setupCommand }}
+

It writes an equivalent repository definition:

+
{{ repoDefinition }}
+
+ +The helper accepts only Fedora 44 on x86_64. It downloads the OpenPGP public key over HTTPS, verifies the complete +fingerprint, and writes only `/etc/yum.repos.d/loopwire.repo` plus the fingerprint-named key under +`/etc/pki/rpm-gpg/`. Repeating setup with the same inputs is safe. An unrelated `loopwire.repo` or a symbolic link in +either managed path is an error; other DNF sources are preserved. Add `--dry-run` and omit `sudo` to preview the +target, URL, key fingerprint, and managed paths without downloads or changes. + +The generated `.repo` file keeps `gpgcheck=1`, `repo_gpgcheck=1`, and `sslverify=1`. DNF verifies the RPM signature and the +detached signature on repository metadata. Do not add `--nogpgcheck`, disable either setting, or copy the local-RPM +signature exception from the direct-download path into this repository path. + +After successful setup, refresh metadata and install: + +```bash +sudo dnf makecache --refresh && +sudo dnf install loopwire +``` + +The setup helper does not install Loopwire. Inspect `dnf repoquery --info --repo=loopwire loopwire` before installation +when you need to confirm the candidate version and repository. Fedora 44's DNF5 can exit successfully after reporting +a bad repository-metadata signature and excluding the source, so require an actual Loopwire package record and no GPG +verification error in the output. The package includes the desktop application and background/provider commands +without enabling startup services or applying audio routes during installation. + +## Update and reinstall + +DNF can update Loopwire with the rest of the system. To update only Loopwire: + +```bash +sudo dnf upgrade --refresh loopwire +``` + +To repair files owned by the installed package without changing versions: + +```bash +sudo dnf reinstall loopwire +``` + +Saved routing configurations are outside package ownership and are preserved. + +## Earlier versions + +List versions retained in the repository with `dnf --showduplicates list loopwire`. DNF does not automatically follow +a repository rollback to an older build. When maintainers recommend rollback, either select the next lower retained +version or name the exact Fedora package version: + +```bash +sudo dnf downgrade loopwire +# Or replace VERSION-RELEASE with an exact retained value, such as 0.1.0-1.fc44: +sudo dnf install loopwire-VERSION-RELEASE.x86_64 +``` + +Do not install an RPM built for another Fedora release or architecture. Ask for recovery guidance if the required +version is absent instead of disabling signature checks. + +## Remove Loopwire or the repository + +Uninstall the application with `sudo dnf remove loopwire`. DNF removes package-owned files and leaves saved Loopwire +configurations intact. Remove startup integration separately if you enabled it through the +[start-on-boot guide](./start-on-boot.md). + +To stop receiving repository updates, use the same inspected helper: + +```bash +sudo bash setup-fedora-repository.sh --remove && +sudo dnf clean metadata +``` + +This removes only the managed Loopwire `.repo` file and its scoped public-key file. It does not uninstall Loopwire, +change Fedora's repositories, or remove keys already accepted into the RPM database. Without the `.repo` file, that +key no longer authorizes a Loopwire source. Manually provisioned definitions and keys require matching manual cleanup. + +## Trust, key changes, and troubleshooting + +The repository OpenPGP key signs the Fedora RPM and repository metadata. It is separate from the OpenSSL release key +that authenticates checksums for direct GitHub Release downloads. The scoped `.repo` configuration does not grant the +Loopwire key authority over other repositories. + +`metadata_expire=6h` asks DNF to refetch metadata after six hours; it is a cache setting, not a signed expiry check. +DNF verifies that metadata was signed by the trusted key, but that signature alone cannot detect replay of older, +correctly signed metadata. Loopwire's publication verifier enforces a custom signed verification deadline and the +project monitors the HTTPS origin. Stop and report an unexpected version rollback rather than forcing installation. + +- **Wrong or changed fingerprint:** stop and compare this page's fingerprint with the project's announced key change. + Rerun the inspected helper only after the successor fingerprint is confirmed through a trusted project channel. +- **Revoked or compromised key:** disable or remove the repository immediately. Do not accept new metadata from that + key. Follow the incident notice to verify and bootstrap a replacement repository explicitly. +- **Bad RPM or metadata signature:** stop the install, run `sudo dnf clean metadata`, retry a refresh, and report the + exact DNF error. Do not disable `gpgcheck`, `repo_gpgcheck`, or TLS verification. +- **Stale or unavailable metadata:** run `sudo dnf clean metadata` and retry. `skip_if_unavailable=False` makes a + missing or invalid Loopwire repository visible instead of silently continuing with stale assumptions. +- **Unsupported Fedora release or architecture:** use the [installation guide](./install.md). Editing `$releasever` or + forcing another repository path does not make its package compatible. + +Useful diagnostics are `rpm -E %fedora`, `uname -m`, `dnf repolist --enabled`, +`dnf repoquery --info --repo=loopwire loopwire`, and the DNF error text. Redact usernames and private URLs. Never send +private keys, environment variables, or audio recordings. diff --git a/apps/docs/docs/guide/install.md b/apps/docs/docs/guide/install.md index 2c39325..15e5805 100644 --- a/apps/docs/docs/guide/install.md +++ b/apps/docs/docs/guide/install.md @@ -92,8 +92,20 @@ sudo apt install loopwire Loopwire is not in the default Ubuntu or Debian repositories. Until the guide displays a verified public channel, use Automatic or the signed manual downloads below. The automatic installer does not configure the APT repository. -The RPM files have no embedded RPM signature. The commands authenticate the download using the signed SHA-256 -manifest first, then permit this local RPM for that install; repository dependency checks remain enabled. +The [Fedora repository guide](./fedora-repository.md) provides the same gated availability record and one-time setup +for Fedora 44 on x86_64. Once that page displays a verified public channel, the normal repository install is: + +```bash +sudo dnf install loopwire +``` + +This is a third-party Loopwire repository, not a Fedora or COPR repository. Until its guide displays a verified +production URL and fingerprint, use Automatic or the signed Fedora direct download below. Automatic continues to use +the direct-download path and does not add the repository. + +The `v0.1.0` direct-download RPM files have no embedded RPM signature. The commands authenticate the download using +the signed SHA-256 manifest first, then permit this local RPM for that install; repository dependency checks remain +enabled. ### Ubuntu 24.04 @@ -141,8 +153,10 @@ sha256sum --check --ignore-missing SHA256SUMS && sudo dnf --setopt=localpkg_gpgcheck=0 install ./loopwire-0.1.0-1.fc44.x86_64.rpm ``` -[Repository work to shorten this setup](https://github.com/sandwichfarm/loopwire/issues/36) tracks signed metadata, -release publication, clean-guest install/upgrade verification, and updated instructions. +[Fedora repository setup and availability](./fedora-repository.md) includes the public activation gate, normal DNF +updates, reinstall, exact-version rollback, removal, and signing-key guidance. Its repository path keeps both package +and metadata signature checks enabled; the local package exception above applies only to this authenticated direct +download. ### openSUSE Tumbleweed @@ -272,8 +286,9 @@ Run the metadata smoke: pnpm verify:packaging ``` -The AppImages and native deb/RPM files are published as direct downloads on the `v0.1.0` GitHub Release. The APT -repository has a separate [public activation gate](./apt-repository.md); DNF/COPR and OBS repositories remain planned. +The AppImages and native deb/RPM files are published as direct downloads on the `v0.1.0` GitHub Release. The APT and +Fedora repositories have separate [APT](./apt-repository.md) and [Fedora](./fedora-repository.md) public activation +gates; the openSUSE repository remains planned. Their matching-guest proof command boots official, checksum-pinned cloud images under KVM and stores local evidence without changing host audio: diff --git a/apps/docs/docs/guide/support-matrix.md b/apps/docs/docs/guide/support-matrix.md index 28d489d..01c6397 100644 --- a/apps/docs/docs/guide/support-matrix.md +++ b/apps/docs/docs/guide/support-matrix.md @@ -72,7 +72,9 @@ the Tauri shell command bridge. | AppImage | Published-artifact and Tauri bundle smoke | Published for 0.1.0 on GitHub Releases. | | Ubuntu 24.04 / Debian 13 deb | Verified in matching KVM guests at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as direct downloads. | | Ubuntu 24.04 / Debian 13 signed APT repository, amd64 | Separate signed-metadata, publication, bootstrap, and clean-guest lifecycle checks | Public activation is gated; see [current channel availability](./apt-repository.md). | -| Fedora 44 / openSUSE Tumbleweed RPM | Verified in matching KVM guests at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as direct downloads; no COPR/OBS repository. | +| Fedora 44 RPM | Verified in a matching KVM guest at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as a direct download. | +| Fedora 44 signed project repository, x86_64 | Signed-RPM/metadata, publication, bootstrap, and clean-guest lifecycle checks | Public activation is gated; see [current channel availability](./fedora-repository.md). | +| openSUSE Tumbleweed RPM | Verified in a matching KVM guest at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as a direct download; no OBS repository. | | AUR `loopwire` | Tagged source build through `pnpm verify:aur:source` | Published for 0.1.0. | | AUR `loopwire-bin` | Signed release-artifact build through `pnpm verify:aur` | Published for 0.1.0. | | AUR `loopwire-git` | Rolling default-branch build through `pnpm verify:aur:git` | Published; development snapshots are not stable releases. | @@ -87,6 +89,11 @@ APT lifecycle evidence is separate from the direct-download snapshot. Isolated H establish a new public release or production channel. Only reviewed production HTTPS verification activates APT instructions. Repository support covers the named distro versions on amd64, not derivatives or other architectures. +Fedora repository lifecycle evidence is separate too. It proves signed RPM and repository-metadata handling, +publication recovery, and install/reinstall/upgrade/downgrade/removal behavior on a clean Fedora 44 x86_64 guest. +Synthetic versions and local HTTPS fixtures are development evidence. They do not prove the production URL is live or +promote Fedora desktop/audio support. Only a reviewed production verification record activates the short DNF command. + Native package verification is narrower than audio-backend support. The committed snapshot proves that each official, checksum-pinned guest built and installed its target package, ran the packaged background/provider/backend commands, resolved GUI libraries, created a Loopwire X11 window under Xvfb, and removed all package-owned files. It does not diff --git a/apps/docs/docs/release-notes/unreleased.md b/apps/docs/docs/release-notes/unreleased.md index dcc19d4..767f5e9 100644 --- a/apps/docs/docs/release-notes/unreleased.md +++ b/apps/docs/docs/release-notes/unreleased.md @@ -16,6 +16,20 @@ These notes describe source-tree progress. They are not a public release announc - Added [user setup and recovery guidance](../guide/apt-repository.md) and the [maintainer publication runbook](../developer/apt-repository.md). +## Signed Fedora repository development + +- Selected a project-owned Fedora repository over COPR so the project can control exact release artifacts, RPM and + metadata signing, atomic promotion, retained snapshots, and rollback verification. +- Added Fedora 44 x86_64 signed-RPM and repository-metadata generation, guarded SSH/POSIX publication, recovery, + public HTTPS verification, and clean-guest lifecycle proof surfaces. +- Added a repeat-safe setup/removal helper that verifies the complete OpenPGP fingerprint and keeps both `gpgcheck=1` + and `repo_gpgcheck=1`. The repository never uses the local direct-download RPM signature exception. +- The Fedora homepage tab switches to `sudo dnf install loopwire` only after a complete public verification record is + reviewed and committed. Production hosting, signing key/environment setup, and first activation remain human + operations; the automatic installer and signed direct-download path remain available. +- Added [Fedora user setup and rollback guidance](../guide/fedora-repository.md) and the + [maintainer operations runbook](../developer/fedora-repository.md). + ## Other distribution updates - Root-level Bunny deployments now pass the post-upload live-site check when `BUNNY_REMOTE_PREFIX` is intentionally @@ -50,8 +64,9 @@ These notes describe source-tree progress. They are not a public release announc release verification key, and handles repeat installs and upgrades. Portable installs stage and verify files before replacing existing files; native packages use the distro package manager. Automatic preserves earlier portable installations, and incomplete rollback retains recovery backups with explicit restoration paths. -- Multi-step manual package instructions link to repository work for [APT](https://github.com/sandwichfarm/loopwire/issues/35), - [Fedora](https://github.com/sandwichfarm/loopwire/issues/36), and [openSUSE](https://github.com/sandwichfarm/loopwire/issues/37). +- Multi-step manual package instructions link to gated repository setup for + [APT](../guide/apt-repository.md) and [Fedora](../guide/fedora-repository.md), while + [openSUSE repository work](https://github.com/sandwichfarm/loopwire/issues/37) remains tracked separately. ## Packaging diff --git a/apps/site/src/lib/rpmChannel.mjs b/apps/site/src/lib/rpmChannel.mjs new file mode 100644 index 0000000..1d8bb88 --- /dev/null +++ b/apps/site/src/lib/rpmChannel.mjs @@ -0,0 +1,65 @@ +/** + * Fedora repository instructions are advertised only after the complete public + * verification record for the supported target has been reviewed and committed. + * Invalid or incomplete records preserve the signed direct-download option. + * @param {unknown} value + */ +export function verifiedFedoraChannel(value) { + if (!value || typeof value !== "object") return null; + const channel = /** @type {Record} */ (value); + if (channel.schemaVersion !== 1 || channel.status !== "verified" || channel.target !== "fedora-44" || + typeof channel.baseUrl !== "string" || !validBaseUrl(channel.baseUrl) || + typeof channel.signingFingerprint !== "string" || !/^[A-F0-9]{40}$/.test(channel.signingFingerprint) || + typeof channel.revision !== "string" || !/^[a-f0-9]{64}$/.test(channel.revision) || + typeof channel.verifiedAt !== "string" || !validTimestamp(channel.verifiedAt) || + typeof channel.proofUrl !== "string" || + !/^https:\/\/github\.com\/sandwichfarm\/loopwire\/actions\/runs\/[1-9][0-9]*$/.test(channel.proofUrl)) { + return null; + } + return { + target: channel.target, + baseUrl: channel.baseUrl.replace(/\/+$/, ""), + signingFingerprint: channel.signingFingerprint, + revision: channel.revision, + verifiedAt: channel.verifiedAt, + proofUrl: channel.proofUrl + }; +} + +/** @param {string} value */ +function validBaseUrl(value) { + try { + const url = new URL(value); + return value.startsWith("https://") && !/[^\x21-\x7e]|[\\'"`$<>?#]/.test(value) && + url.protocol === "https:" && Boolean(url.hostname) && !url.username && !url.password && + (!url.port || Number(url.port) >= 1) && !url.search && !url.hash; + } catch { + return false; + } +} + +/** @param {string} value */ +function validTimestamp(value) { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || + !Number.isFinite(Date.parse(value))) return false; + const date = value.slice(0, 10); + return new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) === date; +} + +/** + * @template {{command: string, note: string, detail: string, href: string, link: string}} T + * @param {unknown} channel + * @param {T} manual + * @returns {T} + */ +export function fedoraInstallOption(channel, manual) { + if (!verifiedFedoraChannel(channel)) return manual; + return { + ...manual, + command: "sudo dnf install loopwire", + note: "After one-time setup, install and update Loopwire through its signed Fedora repository.", + detail: "Fedora 44 on x86_64 is supported. Other releases and architectures use the portable path.", + href: "/docs/guide/fedora-repository.html#one-time-setup", + link: "Set up the Fedora repository" + }; +} diff --git a/apps/site/src/lib/rpmChannel.test.mjs b/apps/site/src/lib/rpmChannel.test.mjs new file mode 100644 index 0000000..7c4c50e --- /dev/null +++ b/apps/site/src/lib/rpmChannel.test.mjs @@ -0,0 +1,81 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { fedoraInstallOption, verifiedFedoraChannel } from "./rpmChannel.mjs"; + +const verified = { + schemaVersion: 1, + status: "verified", + target: "fedora-44", + baseUrl: "https://packages.example.test/fedora/44/x86_64/", + signingFingerprint: "ABCDEF0123456789ABCDEF0123456789ABCDEF01", + revision: "a".repeat(64), + verifiedAt: "2026-09-05T10:20:30+00:00", + proofUrl: "https://github.com/sandwichfarm/loopwire/actions/runs/123456" +}; +const manual = { + id: "fedora", + command: "sudo dnf --setopt=localpkg_gpgcheck=0 install ./loopwire-0.1.0-1.fc44.x86_64.rpm", + note: "Verify the signed download first.", + detail: "Other versions use portable installation.", + href: "/docs/guide/fedora-repository.html", + link: "Fedora repository setup and availability" +}; + +test("pending and incomplete channel records preserve the functional manual option", () => { + for (const value of [null, undefined, {}, [], { ...verified, status: "pending" }]) { + assert.equal(verifiedFedoraChannel(value), null); + assert.equal(fedoraInstallOption(value, manual), manual); + } + for (const key of Object.keys(verified)) { + const value = { ...verified }; + delete value[key]; + assert.equal(verifiedFedoraChannel(value), null, `missing ${key}`); + assert.equal(fedoraInstallOption(value, manual), manual); + } +}); + +test("verified Fedora 44 channel exposes installation and separate setup guidance", () => { + const channel = verifiedFedoraChannel(verified); + assert.equal(channel.target, "fedora-44"); + assert.equal(channel.baseUrl, "https://packages.example.test/fedora/44/x86_64"); + const option = fedoraInstallOption(verified, manual); + assert.equal(option.id, "fedora"); + assert.equal(option.command, "sudo dnf install loopwire"); + assert.equal(option.href, "/docs/guide/fedora-repository.html#one-time-setup"); + assert.doesNotMatch(`${option.note} ${option.detail}`, /workflow|revision|activation|operator/); + assert.match(manual.command, /localpkg_gpgcheck=0/); +}); + +test("malformed or wrong-target records never activate the channel", () => { + const invalid = { + schemaVersion: [0, "1"], status: [true, "ready"], target: ["fedora-43", "Fedora-44", null], + baseUrl: ["http://packages.example.test", "https://user:password@packages.example.test", "https://packages.example.test?a=1", + "https://packages.example.test#fragment", " https://packages.example.test", "https://packages.example.test/\n", "not a URL", + "https://packages.example.test:0", "https://packages.example.test:65536", + "https://packages.example.test?", "https://packages.example.test#", + "https://packages.example.test/$(id)", "https://packages.example.test/`id`", "https://packages.example.test/\\wrong"], + signingFingerprint: ["a".repeat(40), "A".repeat(39), "G".repeat(40)], + revision: ["A".repeat(64), "a".repeat(63)], + verifiedAt: ["yesterday", "2026-09-05", "2026-02-30T00:00:00Z", "2026-09-05T25:00:00Z"], + proofUrl: ["https://github.com/other/repo/actions/runs/123", "https://github.com/sandwichfarm/loopwire/pull/36", + "https://github.com/sandwichfarm/loopwire/actions/runs/123?fixture=1", "https://github.com/sandwichfarm/loopwire/actions/runs/0"] + }; + for (const [key, values] of Object.entries(invalid)) { + for (const value of values) { + const record = { ...verified, [key]: value }; + assert.equal(verifiedFedoraChannel(record), null, `${key}: ${value}`); + assert.equal(fedoraInstallOption(record, manual), manual); + } + } +}); + +test("checked-in Fedora channel remains pending or has a complete verification record", () => { + const channel = JSON.parse(readFileSync(new URL("../../../../packaging/repositories/fedora-channel.json", import.meta.url), "utf8")); + if (channel.status === "pending") { + assert.deepEqual(channel, { schemaVersion: 1, status: "pending", target: null, baseUrl: null, + signingFingerprint: null, revision: null, verifiedAt: null, proofUrl: null }); + } else { + assert.ok(verifiedFedoraChannel(channel)); + } +}); diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index ebaee03..38acfd9 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -2,7 +2,9 @@ import SiteLayout from "../layouts/SiteLayout.astro"; import screenshot from "../../../../assets/product-screenshot.png"; import aptChannel from "../../../../packaging/repositories/apt-channel.json"; +import fedoraChannel from "../../../../packaging/repositories/fedora-channel.json"; import { aptInstallOption } from "../lib/aptChannel.mjs"; +import { fedoraInstallOption } from "../lib/rpmChannel.mjs"; const title = "Loopwire | Linux virtual audio routing"; const description = @@ -61,15 +63,15 @@ const installOptions = [ detail: "Other Debian versions and ARM64 use the portable path.", href: "/docs/guide/apt-repository.html", link: "APT repository setup and availability" }), - { + fedoraInstallOption(fedoraChannel, { id: "fedora", label: "Fedora", heading: "Fedora 44 · x86_64", command: nativeInstall("loopwire-0.1.0-1.fc44.x86_64.rpm", "sudo dnf --setopt=localpkg_gpgcheck=0 install ./loopwire-0.1.0-1.fc44.x86_64.rpm"), note: "Manual signed v0.1.0 download. Run these steps together in an empty folder. The signed checksum " + "authenticates the RPM before DNF installs it.", detail: "The RPM has no embedded signature; the signature exception applies only to local packages. Automatic " + - "handles verification for you. A signed DNF repository is planned.", - href: "https://github.com/sandwichfarm/loopwire/issues/36", link: "Track simpler Fedora installs" - }, + "handles verification for you. The signed DNF repository is pending public verification.", + href: "/docs/guide/fedora-repository.html", link: "Fedora repository setup and availability" + }), { id: "opensuse", label: "openSUSE", heading: "openSUSE Tumbleweed · x86_64", command: nativeInstall("loopwire-0.1.0-1.x86_64.rpm", "sudo zypper install --allow-unsigned-rpm ./loopwire-0.1.0-1.x86_64.rpm"), diff --git a/package.json b/package.json index ae0294b..08efc2c 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "build:site": "pnpm --filter @loopwire/site build", "build:web": "pnpm build:site && pnpm build:docs && node scripts/build-static-site.mjs", "check": "pnpm check:verify && pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm verify:site", - "check:verify": "pnpm verify:requirements && pnpm verify:docs && pnpm test:setup-github && pnpm verify:scripts && pnpm verify:workflows && pnpm verify:runtime && pnpm verify:tauri && pnpm verify:apt", + "check:verify": "pnpm verify:requirements && pnpm verify:docs && pnpm test:setup-github && pnpm verify:scripts && pnpm verify:workflows && pnpm verify:runtime && pnpm verify:tauri && pnpm verify:apt && pnpm verify:rpm-repository", "collect:evidence": "node scripts/collect-release-evidence.mjs", "collect:support": "node scripts/collect-support-bundle.mjs", "release:handoff": "bash scripts/plan-final-release-handoff.sh", @@ -67,6 +67,7 @@ "verify:docs-live": "bash scripts/verify-docs-live.sh", "verify:packaging": "bash scripts/verify-packaging.sh", "verify:apt": "bash scripts/verify-apt-repository.sh", + "verify:rpm-repository": "bash scripts/verify-rpm-repository.sh", "verify:native-packaging": "bash scripts/verify-native-packaging.sh", "build:portable-linux": "bash scripts/build-portable-linux-binary.sh", "package:deb": "bash scripts/build-deb-package.sh", diff --git a/packaging/README.md b/packaging/README.md index ce615a2..ce0d11a 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -145,6 +145,51 @@ pnpm verify:nix-release -- \ Render-only mode proves manifest parsing and expression generation. It never proves the package builds. +## Signed Fedora repository + +The project-owned Fedora channel serves only Fedora 44 x86_64. It reuses the native Fedora recipe below, verifies the +exact RPM against the OpenSSL-signed GitHub Release checksum manifest, signs a staging copy with a dedicated OpenPGP +repository key, and generates signed DNF metadata. The original GitHub Release asset stays unchanged. This path was +selected over COPR to preserve exact release-artifact control, atomic promotion, indefinite retention, and local/CI +proof; maintainers accept responsibility for the SSH/POSIX origin and signing-key lifecycle. + +Build and verify a candidate with the pinned RPM tools image or the equivalent host tools: + +```bash +python3 scripts/rpm-repository.py build \ + --release-dir dist/release --version 0.1.0 --output dist/rpm-repository \ + --signing-key "$RPM_FPR" --gnupg-home "$RPM_GNUPG_HOME" \ + --release-public-key packaging/release-signing-public.pem +python3 scripts/rpm-repository.py verify \ + --repository dist/rpm-repository --public-key rpm-public.asc --fingerprint "$RPM_FPR" +``` + +The public target is `/fedora/44/x86_64/`. Package RPMs, fingerprint-named public keys, checksum-named metadata, and +private snapshots are retained. The SSH publisher installs immutable objects first, writes the new detached +`repomd.xml.asc`, and atomically replaces `repomd.xml` as the metadata commit point. Compare-and-swap revision checks, +a server lock, and a recovery journal prevent stale or incomplete promotion from being treated as success. + +Production publication uses `.github/workflows/publish-fedora.yml` and the protected `packages-production` +environment. `FEDORA_REPOSITORY_ENABLED` remains false until the origin, SSH host pins, dedicated signing identity, +approval policy, and public verification are ready. The checked-in `packaging/repositories/fedora-channel.json` +therefore remains pending and the homepage keeps the existing signed direct-download command. + +The client helper writes only `/etc/yum.repos.d/loopwire.repo` and a fingerprint-named public key under +`/etc/pki/rpm-gpg/`. It keeps `gpgcheck=1` and `repo_gpgcheck=1`; repository installation never uses the local-RPM +signature exception: + +```bash +sudo bash scripts/setup-fedora-repository.sh \ + --base-url 'https://HOST/fedora/44/x86_64' --fingerprint "$RPM_FPR" +sudo dnf makecache --refresh +sudo dnf install loopwire +``` + +These commands are illustrative until the channel record is verified. User instructions must take the URL and full +fingerprint from the [gated Fedora repository guide](../apps/docs/docs/guide/fedora-repository.md), not a source-tree +placeholder. See the [maintainer runbook](../apps/docs/docs/developer/fedora-repository.md) for the provider decision, +production layout, protected configuration, publication/recovery, rollback, caching, key rotation, and activation. + ## Native deb and RPM packages The native package recipes install the complete canonical payload: GUI, background restore, DSP and JACK provider @@ -217,6 +262,8 @@ pnpm verify:release pnpm verify:aur pnpm verify:nix-release -- --version 0.1.0 --release-dir dist/release --render-only pnpm verify:packaging +bash scripts/with-rpm-tools.sh --container python3 scripts/test-rpm-repository.py +node --test apps/site/src/lib/rpmChannel.test.mjs ``` `verify:install` creates a local fake release artifact, signs and verifies `SHA256SUMS`, installs it into a temp prefix, diff --git a/packaging/repositories/Dockerfile.rpm-tools b/packaging/repositories/Dockerfile.rpm-tools new file mode 100644 index 0000000..f015241 --- /dev/null +++ b/packaging/repositories/Dockerfile.rpm-tools @@ -0,0 +1,9 @@ +FROM fedora:44@sha256:be9d65e2344d805cc11114319c685ecaa96b6d9b4350a0a6460cdb931babbd19 + +RUN dnf -y install \ + cpio createrepo_c dnf dnf5-plugins gh git gnupg2 nginx nodejs openssh-clients openssh-server \ + openssl python3 rpm rpm-build rpm-sign \ + && dnf clean all + +ENV PYTHONDONTWRITEBYTECODE=1 +WORKDIR /workspace diff --git a/packaging/repositories/fedora-channel.json b/packaging/repositories/fedora-channel.json new file mode 100644 index 0000000..5aad5fd --- /dev/null +++ b/packaging/repositories/fedora-channel.json @@ -0,0 +1,10 @@ +{ + "schemaVersion": 1, + "status": "pending", + "target": null, + "baseUrl": null, + "signingFingerprint": null, + "revision": null, + "verifiedAt": null, + "proofUrl": null +} diff --git a/packaging/repositories/nginx-rpm.conf b/packaging/repositories/nginx-rpm.conf new file mode 100644 index 0000000..e12e8fe --- /dev/null +++ b/packaging/repositories/nginx-rpm.conf @@ -0,0 +1,35 @@ +# Include these locations in a TLS-enabled server. The SSH publisher writes to +# /srv/loopwire-rpm; only its public child is served. snapshots/ and state/ stay +# private. The DNF base URL is https://HOST/fedora/44/x86_64/. +root /srv/loopwire-rpm/public; +autoindex off; +disable_symlinks on; + +# Interrupted same-filesystem writes may leave hidden temporary files. +location ~ (^|/)\. { + deny all; +} + +# Package, fingerprinted key, and checksum-named repodata URLs never change. +# Old objects remain reachable so clients that cached an older repomd.xml can +# finish after a publication or rollback. +location ~ "^/fedora/44/x86_64/(packages/loopwire-[A-Za-z0-9][A-Za-z0-9.+_~]*-1\.fc44\.x86_64\.rpm|keys/([A-F0-9]{40}|[A-F0-9]{64})\.asc|repodata/[0-9a-f]{64}-(primary|filelists|other)\.xml\.gz)$" { + try_files $uri =404; + error_page 404 = @rpm_missing; + add_header Cache-Control "public, max-age=31536000, immutable"; +} + +location @rpm_missing { + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + return 404; +} + +# repomd.xml is the commit point. The publisher atomically replaces its +# detached signature first and repomd.xml last. Never cache either file: a +# request sequence that crosses publication fails closed under repo_gpgcheck=1 +# and a retry obtains the matched pair. No CDN-wide purge is required. +location /fedora/44/x86_64/ { + try_files $uri =404; + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + etag off; +} diff --git a/packaging/vm/guest-fedora-repository-smoke.sh b/packaging/vm/guest-fedora-repository-smoke.sh new file mode 100755 index 0000000..b0f6250 --- /dev/null +++ b/packaging/vm/guest-fedora-repository-smoke.sh @@ -0,0 +1,358 @@ +#!/usr/bin/env bash +# The unprivileged guest user owns proof logs; sudo applies only to package and trust-store commands. +# shellcheck disable=SC2024 +set -euo pipefail + +target="${1:?target is required}" +package_target="${2:?package target is required}" +format="${3:?format is required}" +version="${4:?version is required}" +git_head="${5:?git head is required}" +kit_dir="${6:-$PWD}" +[ "$target" = fedora-44 ] || { echo "unsupported Fedora repository guest target" >&2; exit 2; } +[ "$package_target" = fedora-44 ] && [ "$format" = rpm ] +[[ "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(\+[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]] +[[ "$git_head" =~ ^[0-9a-f]{40}$ ]] +cd "$kit_dir" + +proof_dir="$kit_dir/proof" +fixture_dir="$kit_dir/fedora-repository-fixture" +release_dir="$kit_dir/release" +public_release_proof="$proof_dir/public-release" +base_url="https://127.0.0.1:8444/fedora/44/x86_64" +upgrade_version="${version}+dnffixture1" +[[ "$version" != *+* ]] || upgrade_version="${version}.dnffixture1" +baseline_package_version="${version}-1.fc44" +upgrade_package_version="${upgrade_version}-1.fc44" +baseline_package="loopwire-${baseline_package_version}.x86_64.rpm" +upgrade_package="loopwire-${upgrade_package_version}.x86_64.rpm" +mkdir -p "$proof_dir/packages" "$proof_dir/repositories" "$public_release_proof" "$fixture_dir" +exec > >(tee "$proof_dir/commands.log") 2>&1 +set -x + +cat /etc/os-release >"$proof_dir/os-release" +uname -a >"$proof_dir/uname.txt" +systemd-detect-virt --vm >"$proof_dir/virtualization.txt" +grep -Eq '^(kvm|qemu)$' "$proof_dir/virtualization.txt" +if rpm -q loopwire >/dev/null 2>&1; then + echo 'clean guest already has Loopwire installed' >&2 + exit 1 +fi +printf 'absent\n' >"$proof_dir/initial-package-status.txt" + +# Authenticate the public GitHub Release manifest before using any release payload as repository input. +openssl dgst -sha256 -verify packaging/release-signing-public.pem \ + -signature "$release_dir/SHA256SUMS.sig" "$release_dir/SHA256SUMS" +python3 - "$release_dir" "$version" "$baseline_package" >"$proof_dir/public-release-validation.json" <<'PY' +import hashlib, json, pathlib, re, sys +root, version, rpm_name = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3] +selected = [rpm_name, "loopwire-linux-x86_64.tar.gz", "release-assets.json"] +entries = {} +for number, line in enumerate((root / "SHA256SUMS").read_text().splitlines(), 1): + match = re.fullmatch(r"([0-9a-f]{64}) ([^/\\\s]+)", line) + assert match and match.group(2) not in entries, f"invalid or duplicate SHA256SUMS entry at line {number}" + entries[match.group(2)] = match.group(1) +for name in selected: + assert list(entries).count(name) == 1, f"SHA256SUMS must contain exactly one {name} entry" + data = (root / name).read_bytes() + assert hashlib.sha256(data).hexdigest() == entries[name], f"signed checksum mismatch: {name}" +manifest = json.loads((root / "release-assets.json").read_text()) +assert set(manifest) == {"schema", "release", "artifacts"} and manifest["schema"] == "loopwire.release-assets.v1" +release = manifest["release"] +assert set(release) == {"tag", "version", "gitHead"} +assert release["tag"] == f"v{version}" and release["version"] == version +assert re.fullmatch(r"[0-9a-f]{40}", release["gitHead"]) +assert isinstance(manifest["artifacts"], list) +fedora = [item for item in manifest["artifacts"] if isinstance(item, dict) and item.get("target") == "fedora-44"] +assert len(fedora) == 1 and set(fedora[0]) == {"name", "kind", "target", "architecture", "bytes", "sha256"} +rpm = fedora[0] +assert (rpm["name"], rpm["kind"], rpm["target"], rpm["architecture"]) == (rpm_name, "native-rpm", "fedora-44", "x86_64") +assert rpm["bytes"] == (root / rpm_name).stat().st_size and rpm["sha256"] == entries[rpm_name] +portable = [item for item in manifest["artifacts"] if isinstance(item, dict) + and item.get("name") == "loopwire-linux-x86_64.tar.gz"] +assert len(portable) == 1 and portable[0].get("kind") == "portable-archive" +assert portable[0].get("target") == "linux-generic" and portable[0].get("architecture") == "x86_64" +assert portable[0].get("bytes") == (root / selected[1]).stat().st_size and portable[0].get("sha256") == entries[selected[1]] +print(json.dumps({"status": "verified", "releaseGitHead": release["gitHead"], + "rpmSha256": entries[rpm_name], "tarSha256": entries[selected[1]], + "manifestSha256": entries[selected[2]]}, sort_keys=True)) +PY +tar -xOf "$release_dir/loopwire-linux-x86_64.tar.gz" RELEASE >"$proof_dir/payload-release.txt" +python3 - "$proof_dir/payload-release.txt" "$version" <<'PY' +import pathlib, re, sys +values = {} +for line in pathlib.Path(sys.argv[1]).read_text().splitlines(): + assert "=" in line + key, value = line.split("=", 1) + assert key not in values + values[key] = value +assert set(values) == {"name", "version", "arch", "source_date_epoch"} +assert values["name"] == "loopwire" and values["version"] == sys.argv[2] and values["arch"] == "x86_64" +assert re.fullmatch(r"[0-9]+", values["source_date_epoch"]) +PY +public_release_git_head="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["releaseGitHead"])' "$proof_dir/public-release-validation.json")" +printf '%s\n' "$public_release_git_head" >"$proof_dir/public-release-git-head.txt" +cp "$release_dir/$baseline_package" "$public_release_proof/" +cp "$release_dir/loopwire-linux-x86_64.tar.gz" "$public_release_proof/" +cp "$release_dir/SHA256SUMS" "$public_release_proof/" +cp "$release_dir/SHA256SUMS.sig" "$public_release_proof/" +cp "$release_dir/release-assets.json" "$public_release_proof/" +cp packaging/release-signing-public.pem "$public_release_proof/" +cp "$proof_dir/payload-release.txt" "$public_release_proof/RELEASE" +(cd "$release_dir" && sha256sum loopwire-linux-x86_64.tar.gz) >"$proof_dir/release-payload.sha256" + +sudo dnf install -y \ + ca-certificates cpio createrepo_c curl findutils gnupg2 gzip nodejs openssl python3 \ + rpm-build rpm-sign tar xdotool xorg-x11-server-Xvfb + +# Signing material exists only inside this disposable guest. Evidence receives public keys only. +gnupg_home="$fixture_dir/gnupg" +mkdir -m 0700 "$gnupg_home" +gpg --homedir "$gnupg_home" --batch --pinentry-mode loopback --passphrase '' \ + --quick-generate-key 'Loopwire disposable Fedora repository guest fixture' rsa3072 sign 0 +fingerprint="$(gpg --homedir "$gnupg_home" --batch --with-colons --list-keys | + awk -F: '$1 == "fpr" { print $10; exit }')" +[[ "$fingerprint" =~ ^[0-9A-F]{40}$ ]] +gpg --homedir "$gnupg_home" --batch --armor --export "$fingerprint" >"$proof_dir/repository-key.asc" +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$fixture_dir/synthetic-release-key.pem" +openssl pkey -in "$fixture_dir/synthetic-release-key.pem" -pubout -out "$fixture_dir/synthetic-release-public.pem" +cp "$fixture_dir/synthetic-release-public.pem" "$proof_dir/synthetic-release-public.pem" + +build_fixture_release() { + local fixture_version="$1" destination="$2" package_name package_sha + mkdir -p "$destination" + SOURCE_DATE_EPOCH=0 bash scripts/build-rpm-package.sh --target fedora-44 \ + --version "$fixture_version" --arch x86_64 --release-dir "$release_dir" --output-dir "$destination" + package_name="loopwire-${fixture_version}-1.fc44.x86_64.rpm" + [ -f "$destination/$package_name" ] + [ "$(find "$destination" -maxdepth 1 -type f -name '*.rpm' | wc -l)" -eq 1 ] + package_sha="$(sha256sum "$destination/$package_name" | awk '{ print $1 }')" + printf '%s %s\n' "$package_sha" "$package_name" >"$destination/SHA256SUMS" + openssl dgst -sha256 -sign "$fixture_dir/synthetic-release-key.pem" \ + -out "$destination/SHA256SUMS.sig" "$destination/SHA256SUMS" +} + +build_fixture_release "$upgrade_version" "$fixture_dir/upgrade-release" +baseline_source_sha256="$(sha256sum "$release_dir/$baseline_package" | awk '{ print $1 }')" +upgrade_source_sha256="$(sha256sum "$fixture_dir/upgrade-release/$upgrade_package" | awk '{ print $1 }')" +printf 'baseline\t%s\t%s\nupgraded\t%s\t%s\n' \ + "$baseline_package" "$baseline_source_sha256" "$upgrade_package" "$upgrade_source_sha256" \ + >"$proof_dir/release-sources.tsv" + +python3 scripts/rpm-repository.py build --release-dir "$release_dir" --version "$version" \ + --output "$fixture_dir/initial" --signing-key "$fingerprint" --gnupg-home "$gnupg_home" +python3 scripts/rpm-repository.py build --release-dir "$fixture_dir/upgrade-release" --version "$upgrade_version" \ + --output "$fixture_dir/upgraded" --previous "$fixture_dir/initial" --signing-key "$fingerprint" \ + --gnupg-home "$gnupg_home" --release-public-key "$fixture_dir/synthetic-release-public.pem" +python3 scripts/rpm-repository.py rollback --repository "$fixture_dir/initial" \ + --output "$fixture_dir/rolled-back" --signing-key "$fingerprint" --gnupg-home "$gnupg_home" + +for repository_stage in initial upgraded rolled-back; do + python3 scripts/rpm-repository.py verify --repository "$fixture_dir/$repository_stage" \ + --public-key "$proof_dir/repository-key.asc" --fingerprint "$fingerprint" \ + >"$proof_dir/repositories/${repository_stage}-verification.json" + cp -a "$fixture_dir/$repository_stage" "$proof_dir/repositories/$repository_stage" +done +cp "$fixture_dir/initial/packages/$baseline_package" "$proof_dir/packages/" +cp "$fixture_dir/upgraded/packages/$upgrade_package" "$proof_dir/packages/" +baseline_rpm_sha256="$(sha256sum "$proof_dir/packages/$baseline_package" | awk '{ print $1 }')" +upgrade_rpm_sha256="$(sha256sum "$proof_dir/packages/$upgrade_package" | awk '{ print $1 }')" +printf 'baseline\t%s\t%s\nupgraded\t%s\t%s\n' \ + "$baseline_package" "$baseline_rpm_sha256" "$upgrade_package" "$upgrade_rpm_sha256" \ + >"$proof_dir/signed-packages.tsv" + +# Verify each distributed RPM against the repository key without changing the guest's system RPM database. +fixture_rpmdb="/var/tmp/loopwire-fedora-proof-rpmdb-${git_head}" +server_pid="" +cleanup() { + [ -z "$server_pid" ] || kill "$server_pid" 2>/dev/null || true + sudo rm -rf -- "$fixture_rpmdb" +} +trap cleanup EXIT +sudo rm -rf -- "$fixture_rpmdb" +sudo rpmkeys --dbpath "$fixture_rpmdb" --import "$proof_dir/repository-key.asc" +sudo rpm --dbpath "$fixture_rpmdb" -Kv "$proof_dir/packages/$baseline_package" >"$proof_dir/packages/baseline-rpm-signature.txt" +sudo rpm --dbpath "$fixture_rpmdb" -Kv "$proof_dir/packages/$upgrade_package" >"$proof_dir/packages/upgraded-rpm-signature.txt" + +# A guest-only CA exercises real TLS verification; no production trust is imported. +openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=Loopwire disposable guest CA' \ + -keyout "$fixture_dir/ca-key.pem" -out "$fixture_dir/ca.crt" \ + -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=critical,keyCertSign,cRLSign' +openssl req -newkey rsa:2048 -nodes -subj '/CN=127.0.0.1' \ + -keyout "$fixture_dir/tls-key.pem" -out "$fixture_dir/tls.csr" +printf 'subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n' \ + >"$fixture_dir/tls.ext" +openssl x509 -req -in "$fixture_dir/tls.csr" -CA "$fixture_dir/ca.crt" -CAkey "$fixture_dir/ca-key.pem" \ + -CAcreateserial -days 1 -extfile "$fixture_dir/tls.ext" -out "$fixture_dir/tls.crt" +cp "$fixture_dir/ca.crt" "$proof_dir/tls-ca.crt" +cp "$fixture_dir/tls.crt" "$proof_dir/tls-server.crt" +sudo install -m 0644 "$fixture_dir/ca.crt" /etc/pki/ca-trust/source/anchors/loopwire-guest-fixture.crt +sudo update-ca-trust +mkdir -p "$fixture_dir/www/fedora/44" +ln -s "$fixture_dir/initial" "$fixture_dir/www/fedora/44/x86_64" +cat >"$fixture_dir/https-server.py" <<'PY' +import functools +import http.server +import ssl +import sys +class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + # Repository revisions can share a timestamp to the second. Always serve current signed bytes. + if "If-Modified-Since" in self.headers: + del self.headers["If-Modified-Since"] + super().do_GET() +class Server(http.server.ThreadingHTTPServer): + def shutdown_request(self, request): + request.settimeout(5) + try: + request.unwrap() + except (OSError, ssl.SSLError): + request.close() +server = Server(("127.0.0.1", 8444), functools.partial(Handler, directory=sys.argv[1])) +context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) +context.load_cert_chain(sys.argv[2], sys.argv[3]) +server.socket = context.wrap_socket(server.socket, server_side=True) +server.serve_forever() +PY +python3 "$fixture_dir/https-server.py" "$fixture_dir/www" "$fixture_dir/tls.crt" "$fixture_dir/tls-key.pem" \ + >"$proof_dir/https-server.log" 2>&1 & +server_pid=$! +for _attempt in $(seq 1 20); do + if curl --fail --silent --show-error "$base_url/keys/$fingerprint.asc" >"$proof_dir/https-key.asc"; then break; fi + sleep 1 +done +cmp "$proof_dir/repository-key.asc" "$proof_dir/https-key.asc" + +verify_public_stage() { + local stage="$1" + python3 scripts/verify-rpm-public.py --repository "$fixture_dir/$stage" \ + --public-key "$proof_dir/repository-key.asc" --fingerprint "$fingerprint" \ + --base-url "$base_url" --ca-file "$fixture_dir/ca.crt" \ + | tee "$proof_dir/repositories/${stage}-public-verification.json" +} + +verify_public_stage initial +sudo bash scripts/setup-fedora-repository.sh --base-url "$base_url" --fingerprint "$fingerprint" \ + >"$proof_dir/bootstrap.log" 2>&1 +cat /etc/yum.repos.d/loopwire.repo >"$proof_dir/loopwire.repo" +cat "/etc/pki/rpm-gpg/RPM-GPG-KEY-loopwire-$fingerprint" >"$proof_dir/configured-repository-key.asc" +cmp "$proof_dir/repository-key.asc" "$proof_dir/configured-repository-key.asc" +sudo dnf makecache --refresh -y >"$proof_dir/bootstrap-makecache.log" 2>&1 + +smoke_installed() { + local stage="$1" expected_version="$2" package_name="$3" stage_dir="$proof_dir/$1" + mkdir -p "$stage_dir" + rpm -q --qf '%{NAME}\t%{VERSION}-%{RELEASE}\t%{ARCH}\n' loopwire >"$stage_dir/package-metadata.tsv" + [ "$(rpm -q --qf '%{VERSION}-%{RELEASE}' loopwire)" = "$expected_version" ] + dnf repoquery --installed --qf '%{name}|%{evr}|%{arch}|%{from_repo}' loopwire >"$stage_dir/dnf-origin.txt" + grep -Fxq "loopwire|$expected_version|x86_64|loopwire" "$stage_dir/dnf-origin.txt" + dnf info --installed loopwire >"$stage_dir/dnf-info.txt" + grep -Eq '^From repository[[:space:]]*:[[:space:]]*loopwire$' "$stage_dir/dnf-info.txt" + rpm -ql loopwire | sort >"$stage_dir/package-files.txt" + while IFS= read -r installed_file; do + if [ -f "$installed_file" ]; then sha256sum "$installed_file"; fi + done <"$stage_dir/package-files.txt" >"$stage_dir/installed-files.sha256" + printf '%s %s\n' "$(sha256sum "$proof_dir/packages/$package_name" | awk '{ print $1 }')" "$package_name" \ + >"$stage_dir/signed-package.sha256" + rpm --dbpath "$fixture_rpmdb" -Kv "$proof_dir/packages/$package_name" >"$stage_dir/rpm-signature.txt" + loopwire --background --help >"$stage_dir/background-help.txt" + loopwire-dsp-provider --help >"$stage_dir/dsp-provider-help.txt" + loopwire-jack-ports --help >"$stage_dir/jack-provider-help.txt" + loopwire-detect-audio --pretty >"$stage_dir/detect-audio.json" + ldd /usr/lib/loopwire/loopwire-gui >"$stage_dir/gui-ldd.txt" + if grep -Fq 'not found' "$stage_dir/gui-ldd.txt"; then + echo 'Installed GUI has unresolved shared libraries' >&2 + return 1 + fi + local gui_status=0 + # Runtime process/window variables must expand in the child shell. + # shellcheck disable=SC2016 + timeout 35s bash -c ' + stage_dir="$1" + app_pid="" + Xvfb :99 -screen 0 1280x720x24 -nolisten tcp >"$stage_dir/xvfb.log" 2>&1 & + xvfb_pid=$! + cleanup_gui() { [ -z "$app_pid" ] || kill "$app_pid" 2>/dev/null || true; kill "$xvfb_pid" 2>/dev/null || true; wait || true; } + trap cleanup_gui EXIT + sleep 1 + DISPLAY=:99 GDK_BACKEND=x11 WEBKIT_DISABLE_DMABUF_RENDERER=1 \ + /usr/lib/loopwire/loopwire-gui >"$stage_dir/gui-launch.log" 2>&1 & + app_pid=$! + for attempt in $(seq 1 20); do + kill -0 "$app_pid" 2>/dev/null || exit 1 + if DISPLAY=:99 xdotool search --name "^(Loopwire|loopwire-gui)$" >"$stage_dir/gui-window-ids.txt" 2>/dev/null; then + while read -r window_id; do DISPLAY=:99 xdotool getwindowname "$window_id"; done \ + <"$stage_dir/gui-window-ids.txt" >"$stage_dir/gui-window-names.txt" + exit 0 + fi + sleep 1 + done + exit 124 + ' bash "$stage_dir" || gui_status=$? + printf '%s\n' "$gui_status" >"$stage_dir/gui-launch-status.txt" + [ "$gui_status" -eq 0 ] + [ -s "$stage_dir/gui-window-ids.txt" ] + if grep -Eiq 'error while loading shared libraries|panic|protocol error|missing acquire timeline' "$stage_dir/gui-launch.log"; then + echo 'Installed GUI reported a startup failure' >&2 + return 1 + fi + printf '%s\t%s\tinstalled\n' "$stage" "$expected_version" >>"$proof_dir/lifecycle.tsv" +} + +sudo dnf install -y "loopwire-$baseline_package_version.x86_64" >"$proof_dir/install.log" 2>&1 +smoke_installed install "$baseline_package_version" "$baseline_package" +sudo dnf reinstall -y "loopwire-$baseline_package_version.x86_64" >"$proof_dir/reinstall.log" 2>&1 +smoke_installed reinstall "$baseline_package_version" "$baseline_package" +ln -sfn "$fixture_dir/upgraded" "$fixture_dir/www/fedora/44/x86_64" +verify_public_stage upgraded +sudo dnf makecache --refresh -y >"$proof_dir/upgrade-makecache.log" 2>&1 +sudo dnf upgrade -y loopwire >"$proof_dir/upgrade.log" 2>&1 +smoke_installed upgrade "$upgrade_package_version" "$upgrade_package" +ln -sfn "$fixture_dir/rolled-back" "$fixture_dir/www/fedora/44/x86_64" +verify_public_stage rolled-back +sudo dnf makecache --refresh -y >"$proof_dir/rollback-makecache.log" 2>&1 +sudo dnf downgrade -y "loopwire-$baseline_package_version.x86_64" >"$proof_dir/rollback.log" 2>&1 +smoke_installed rollback "$baseline_package_version" "$baseline_package" +sudo dnf remove -y loopwire >"$proof_dir/remove.log" 2>&1 +if rpm -q loopwire >/dev/null 2>&1; then + echo 'Loopwire remains registered after removal' >&2 + exit 1 +fi +for removed_file in /usr/bin/loopwire /usr/bin/loopwire-dsp-provider /usr/bin/loopwire-jack-ports \ + /usr/bin/loopwire-detect-audio /usr/lib/loopwire /usr/share/applications/loopwire.desktop \ + /usr/share/icons/hicolor/scalable/apps/loopwire.svg; do + test ! -e "$removed_file" + printf '%s\tabsent\n' "$removed_file" >>"$proof_dir/removed-files.tsv" +done +printf 'remove\t%s\tabsent\n' "$baseline_package_version" >>"$proof_dir/lifecycle.tsv" +sudo bash scripts/setup-fedora-repository.sh --remove >"$proof_dir/source-removal.log" 2>&1 +test ! -e /etc/yum.repos.d/loopwire.repo +test ! -e "/etc/pki/rpm-gpg/RPM-GPG-KEY-loopwire-$fingerprint" +sudo dnf clean all >"$proof_dir/source-removal-clean.log" 2>&1 +dnf repo list --all >"$proof_dir/source-removal-repositories.txt" +if grep -Eq '(^|[[:space:]])loopwire([[:space:]]|$)' "$proof_dir/source-removal-repositories.txt"; then + echo 'DNF still lists the removed repository' >&2 + exit 1 +fi + +{ + printf 'schema\tloopwire.fedora-repository-vm-proof.v1\n' + printf 'target\t%s\n' "$target" + printf 'git_head\t%s\n' "$git_head" + printf 'version\t%s\n' "$version" + printf 'upgrade_version\t%s\n' "$upgrade_version" + printf 'baseline_package_version\t%s\n' "$baseline_package_version" + printf 'upgrade_package_version\t%s\n' "$upgrade_package_version" + printf 'fingerprint\t%s\n' "$fingerprint" + printf 'base_url\t%s\n' "$base_url" + printf 'payload_kind\tpublic-release-baseline-with-synthetic-upgrade\n' + printf 'synthetic_upgrade\ttrue\n' + printf 'public_release_git_head\t%s\n' "$public_release_git_head" + printf 'baseline_source_sha256\t%s\n' "$baseline_source_sha256" + printf 'upgrade_source_sha256\t%s\n' "$upgrade_source_sha256" + printf 'baseline_rpm_sha256\t%s\n' "$baseline_rpm_sha256" + printf 'upgrade_rpm_sha256\t%s\n' "$upgrade_rpm_sha256" + printf 'verification_epoch\t%s\n' "$(date +%s)" +} >"$proof_dir/summary.tsv" +set +x +echo "Fedora repository lifecycle proof passed: $target" diff --git a/scripts/native-package-vm.sh b/scripts/native-package-vm.sh index 3f41104..e4ac9cf 100755 --- a/scripts/native-package-vm.sh +++ b/scripts/native-package-vm.sh @@ -25,17 +25,20 @@ Usage: native-package-vm.sh verify-all [--git-head COMMIT] native-package-vm.sh run-apt --target ubuntu-24.04|debian-13 --version VERSION --release-dir DIR native-package-vm.sh verify-apt --target ubuntu-24.04|debian-13 [--git-head COMMIT] + native-package-vm.sh run-fedora-repo --target fedora-44 --version VERSION --release-dir DIR + native-package-vm.sh verify-fedora-repo --target fedora-44 [--git-head COMMIT] Environment: LOOPWIRE_NATIVE_VM_ROOT Cache/run/evidence root (default: .vm/native-packages) LOOPWIRE_APT_VM_ROOT APT run/evidence root (default: .vm/apt-repository) + LOOPWIRE_FEDORA_VM_ROOT Fedora repository run/evidence root (default: .vm/fedora-repository) LOOPWIRE_NATIVE_VM_TARGETS Target manifest override LOOPWIRE_QEMU_IMAGE Docker QEMU tool image tag The host needs Docker, OpenSSH, /dev/kvm access, and enough disk for the official cloud images. Containers only provide QEMU tools; every proof is collected from -a separately booted guest kernel. APT proofs use verify-apt-repository-vm-proof.mjs; -the original native-package proofs use verify-native-package-vm-proof.mjs. +a separately booted guest kernel. Repository proofs use their matching strict +VM-proof verifier; the original package proofs use verify-native-package-vm-proof.mjs. USAGE } @@ -252,6 +255,14 @@ run_target() { [ -d "$release_dir" ] || fail "release directory does not exist: $release_dir" [ -f "$release_dir/loopwire-linux-x86_64.tar.gz" ] || fail "release tarball is missing" [ -f "$release_dir/SHA256SUMS" ] || fail "release checksum manifest is missing" + if [ "$proof_kind" = "fedora-repository" ]; then + for release_file in \ + "loopwire-${version}-1.fc44.x86_64.rpm" \ + SHA256SUMS.sig \ + release-assets.json; do + [ -f "$release_dir/$release_file" ] || fail "Fedora repository release artifact is missing: $release_file" + done + fi require_host require_committed_implementation download_target "$selected" @@ -271,6 +282,9 @@ run_target() { if [ "$proof_kind" = "apt" ]; then container="loopwire-apt-$id" port=$((port + 10)) + elif [ "$proof_kind" = "fedora-repository" ]; then + container="loopwire-fedora-repository-$id" + port=$((port + 20)) fi key="$(ensure_ssh_key)" public_key="$(cat "${key}.pub")" @@ -283,6 +297,11 @@ run_target() { git archive --format=tar HEAD | tar -xf - -C "$target_dir/kit" cp "$release_dir/loopwire-linux-x86_64.tar.gz" "$target_dir/kit/release/" cp "$release_dir/SHA256SUMS" "$target_dir/kit/release/" + if [ "$proof_kind" = "fedora-repository" ]; then + cp "$release_dir/loopwire-${version}-1.fc44.x86_64.rpm" "$target_dir/kit/release/" + cp "$release_dir/SHA256SUMS.sig" "$target_dir/kit/release/" + cp "$release_dir/release-assets.json" "$target_dir/kit/release/" + fi write_cloud_init "$target_dir" "$public_key" "$id" docker run --rm -v "$target_dir:/vm" "$qemu_image" \ @@ -321,6 +340,9 @@ run_target() { if [ "$proof_kind" = "apt" ]; then guest_script="packaging/vm/guest-apt-repository-smoke.sh" verifier="scripts/verify-apt-repository-vm-proof.mjs" + elif [ "$proof_kind" = "fedora-repository" ]; then + guest_script="packaging/vm/guest-fedora-repository-smoke.sh" + verifier="scripts/verify-fedora-repository-vm-proof.mjs" fi local guest_status=0 # Script paths are fixed and all client-expanded arguments are validated above. @@ -350,6 +372,7 @@ run_target() { trap - EXIT INT TERM local proof_label="native package" [ "$proof_kind" != "apt" ] || proof_label="APT repository lifecycle" + [ "$proof_kind" != "fedora-repository" ] || proof_label="Fedora repository lifecycle" echo "Verified $proof_label in matching KVM guest: $id" echo "Evidence: $evidence_dir" } @@ -360,6 +383,7 @@ verify_target() { [ -n "$git_head" ] || git_head="$(git rev-parse HEAD)" local verifier="scripts/verify-native-package-vm-proof.mjs" [ "$proof_kind" != "apt" ] || verifier="scripts/verify-apt-repository-vm-proof.mjs" + [ "$proof_kind" != "fedora-repository" ] || verifier="scripts/verify-fedora-repository-vm-proof.mjs" node "$verifier" \ --target "$selected" --evidence-dir "$vm_root/evidence/$selected/$git_head" --git-head "$git_head" } @@ -398,6 +422,13 @@ case "$command" in [ "$(realpath -m "$vm_root")" != "$(realpath -m "$image_root")" ] || fail "APT VM state must use a different root from native-package state" ;; + run-fedora-repo | verify-fedora-repo) + [ "$selected" = "fedora-44" ] || fail "$command requires the Fedora 44 target" + proof_kind="fedora-repository" + vm_root="${LOOPWIRE_FEDORA_VM_ROOT:-.vm/fedora-repository}" + [ "$(realpath -m "$vm_root")" != "$(realpath -m "$image_root")" ] || + fail "Fedora repository VM state must use a different root from native-package state" + ;; esac case "$command" in @@ -416,7 +447,7 @@ case "$command" in require_host while read -r id; do download_target "$id"; done < <(target_ids) ;; - run | run-apt) + run | run-apt | run-fedora-repo) [ -n "$selected" ] || fail "run requires --target" [ -n "$version" ] || fail "run requires --version" [ -n "$release_dir" ] || fail "run requires --release-dir" @@ -427,7 +458,7 @@ case "$command" in [ -n "$release_dir" ] || fail "run-all requires --release-dir" while read -r id; do run_target "$id" "$version" "$release_dir"; done < <(target_ids) ;; - verify | verify-apt) + verify | verify-apt | verify-fedora-repo) [ -n "$selected" ] || fail "verify requires --target" verify_target "$selected" "$git_head" ;; diff --git a/scripts/publish-fedora-workflow.sh b/scripts/publish-fedora-workflow.sh new file mode 100755 index 0000000..e9d439d --- /dev/null +++ b/scripts/publish-fedora-workflow.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +fail() { printf 'publish-fedora-workflow: %s\n' "$*" >&2; exit 1; } +for name in FEDORA_REPOSITORY_URL FEDORA_REPOSITORY_HOST FEDORA_REPOSITORY_ROOT FEDORA_SIGNING_FINGERPRINT \ + FEDORA_SSH_PRIVATE_KEY FEDORA_SSH_KNOWN_HOSTS FEDORA_SIGNING_KEY RUNNER_TEMP GITHUB_REPOSITORY GITHUB_SERVER_URL GITHUB_RUN_ID; do + [ -n "${!name:-}" ] || fail "missing configuration: $name" +done +operation="${OPERATION:-refresh}" +case "$operation" in + publish) [[ "${RELEASE_TAG:-}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "publish requires a stable vX.Y.Z release tag" ;; + refresh) ;; + rollback) [[ "${ROLLBACK_REVISION:-}" =~ ^[a-f0-9]{64}$ ]] || fail "rollback requires a retained revision SHA-256" ;; + *) fail "unknown operation" ;; +esac +[[ "$FEDORA_SIGNING_FINGERPRINT" =~ ^[A-F0-9]{40}$ ]] || fail "signing fingerprint must be complete uppercase hexadecimal" +[[ "${FEDORA_SSH_PORT:-22}" =~ ^[0-9]+$ ]] || fail "SSH port must be numeric" +python3 - "$FEDORA_REPOSITORY_URL" <<'PY' +import runpy, sys +validate = runpy.run_path('scripts/verify-rpm-public.py')['validate_base_url'] +try: + validate(sys.argv[1]) +except ValueError as error: + sys.exit(f'publish-fedora-workflow: {error}') +PY + +work="$(mktemp -d "$RUNNER_TEMP/loopwire-fedora.XXXXXX")" +cleanup() { + gpgconf --homedir "$work/gnupg" --kill all >/dev/null 2>&1 || true + rm -rf -- "$work" +} +trap cleanup EXIT +umask 077 +mkdir "$work/gnupg" +printf '%s\n' "$FEDORA_SSH_PRIVATE_KEY" >"$work/ssh-key" +printf '%s\n' "$FEDORA_SSH_KNOWN_HOSTS" >"$work/known-hosts" +printf '%s\n' "$FEDORA_SIGNING_KEY" >"$work/signing-key.asc" +gpg --no-options --batch --homedir "$work/gnupg" --import "$work/signing-key.asc" +gpg --no-options --batch --homedir "$work/gnupg" --armor --export "$FEDORA_SIGNING_FINGERPRINT" >"$work/public-key.asc" +[ -s "$work/public-key.asc" ] || fail "configured key does not match the expected fingerprint" +sign_args=(--signing-key "$FEDORA_SIGNING_FINGERPRINT" --gnupg-home "$work/gnupg" --valid-for-days 30) +if [ -n "${FEDORA_SIGNING_PASSPHRASE:-}" ]; then + printf '%s' "$FEDORA_SIGNING_PASSPHRASE" >"$work/passphrase" + sign_args+=(--passphrase-file "$work/passphrase") +fi +unset FEDORA_SSH_PRIVATE_KEY FEDORA_SSH_KNOWN_HOSTS FEDORA_SIGNING_KEY FEDORA_SIGNING_PASSPHRASE +transport=(--root "$FEDORA_REPOSITORY_ROOT" --ssh "$FEDORA_REPOSITORY_HOST" --ssh-port "${FEDORA_SSH_PORT:-22}" + --identity-file "$work/ssh-key" --known-hosts "$work/known-hosts" + --public-key "$work/public-key.asc" --fingerprint "$FEDORA_SIGNING_FINGERPRINT") + +set +e +python3 scripts/publish-rpm-repository.py fetch "${transport[@]}" --output "$work/current" +fetch_status=$? +set -e +previous_args=() +if [ "$fetch_status" -eq 0 ]; then + expected="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["revision"])' \ + "$work/current/repository-manifest.json")" + previous_args=(--previous "$work/current") +elif [ "$fetch_status" -eq 3 ] && [ "$operation" = publish ]; then + expected=empty +else + fail "could not load the existing Fedora repository (status $fetch_status); no publication attempted" +fi + +case "$operation" in + publish) + gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease,tagName >"$work/release.json" + python3 - "$work/release.json" "$RELEASE_TAG" <<'PY' +import json, sys +release = json.load(open(sys.argv[1])) +if release['isDraft'] or release['isPrerelease'] or release['tagName'] != sys.argv[2]: + sys.exit('Fedora publication requires the requested published stable release') +PY + mkdir "$work/release" + gh release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --dir "$work/release" + release_commit="$(git rev-parse --verify "refs/tags/${RELEASE_TAG}^{commit}")" + bash scripts/verify-release-signature.sh --release-dir "$work/release" --public-key packaging/release-signing-public.pem + node scripts/release-asset-manifest.mjs verify --release-dir "$work/release" --tag "$RELEASE_TAG" \ + --git-head "$release_commit" --require-checksum --require-evidence + python3 scripts/rpm-repository.py build --release-dir "$work/release" --version "${RELEASE_TAG#v}" \ + --output "$work/candidate" "${sign_args[@]}" "${previous_args[@]}" + ;; + refresh) + python3 scripts/rpm-repository.py rollback --repository "$work/current" --output "$work/candidate" "${sign_args[@]}" + ;; + rollback) + python3 scripts/publish-rpm-repository.py fetch "${transport[@]}" --revision "$ROLLBACK_REVISION" \ + --output "$work/rollback" + python3 scripts/rpm-repository.py rollback --repository "$work/rollback" --output "$work/candidate" "${sign_args[@]}" + ;; +esac + +mkdir -p dist/fedora-publication +python3 scripts/publish-rpm-repository.py publish "${transport[@]}" --repository "$work/candidate" \ + --expected-revision "$expected" >dist/fedora-publication/publication.json +python3 scripts/verify-rpm-public.py --repository "$work/candidate" --public-key "$work/public-key.asc" \ + --fingerprint "$FEDORA_SIGNING_FINGERPRINT" --base-url "$FEDORA_REPOSITORY_URL" \ + --proof-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + --output dist/fedora-publication/fedora-channel.json +cp "$work/candidate/repository-manifest.json" dist/fedora-publication/repository-manifest.json +printf 'Fedora repository published and verified; activation record is in the workflow artifact.\n' diff --git a/scripts/publish-rpm-repository.py b/scripts/publish-rpm-repository.py new file mode 100644 index 0000000..f727b1e --- /dev/null +++ b/scripts/publish-rpm-repository.py @@ -0,0 +1,782 @@ +#!/usr/bin/env python3 +"""Publish a verified Fedora RPM repository to a POSIX origin. + +ROOT/public is the HTTP document root. The supported DNF base URL is +ROOT/public/fedora/44/x86_64. ROOT/snapshots and ROOT/state are private. +Package, key, and checksum-named repodata URLs are immutable and retained. + +RPM metadata uses a fail-closed commit protocol: repomd.xml.asc is replaced +first and repomd.xml is replaced atomically last. A client crossing that +boundary can observe a signature mismatch, but repo_gpgcheck=1 cannot accept a +partially published repository. The durable pending journal must be completed +before another revision may be published. + +The SSH transport executes this same source with Python 3 on the origin. The +client verifies OpenPGP signatures; no signing key or GPG program is sent to the +origin. Remote use requires a pinned known_hosts file and an explicit identity +file. The origin filesystem must implement flock, fsync, and same-directory +atomic rename. +""" + +import argparse +import base64 +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shlex +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import time + + +MANIFEST = "repository-manifest.json" +SCHEMA = "loopwire.rpm-repository.v1" +TARGET = {"distribution": "fedora", "release": "44", "architecture": "x86_64"} +PUBLIC_PREFIX = Path("fedora/44/x86_64") +REVISION = re.compile(r"[0-9a-f]{64}\Z") +FINGERPRINT = re.compile(r"(?:[A-F0-9]{40}|[A-F0-9]{64})\Z") +VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?" +PACKAGE_PATH = re.compile(rf"packages/loopwire-{VERSION}-1\.fc44\.x86_64\.rpm\Z") +REPODATA_PATH = re.compile( + r"repodata/[0-9a-f]{64}-(?:primary|filelists|other)\.xml\.gz\Z" +) +MANIFEST_FIELDS = { + "schema", "schemaVersion", "revision", "signingFingerprint", + "createdAt", "validUntil", "target", "packages", "files", +} +PACKAGE_FIELDS = { + "name", "version", "release", "architecture", "path", + "sourceReleaseSha256", "distributedSha256", "size", +} +FILE_FIELDS = {"path", "sha256", "size", "kind"} + + +class PublicationError(Exception): + """An actionable publication failure without private transport details.""" + + +class EmptyRepository(PublicationError): + """No committed snapshot exists (distinct exit status 3).""" + + +def require(condition, message): + if not condition: + raise PublicationError(message) + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def object_pairs(pairs): + result = {} + for key, value in pairs: + require(key not in result, "duplicate repository JSON field") + result[key] = value + return result + + +def digest(path): + with path.open("rb") as source: + if hasattr(hashlib, "file_digest"): + return hashlib.file_digest(source, "sha256").hexdigest() + result = hashlib.sha256() + for chunk in iter(lambda: source.read(1024 * 1024), b""): + result.update(chunk) + return result.hexdigest() + + +def safe_path(value): + require(isinstance(value, str) and value and "\\" not in value, + "inventory contains an invalid path") + path = PurePosixPath(value) + require(not path.is_absolute() and path.as_posix() == value + and all(part not in (".", "..") for part in path.parts), + "inventory path must be normalized and relative") + return path + + +def classify(path): + """Derive URL mutability from the protocol, never from manifest input.""" + safe_path(path) + if PACKAGE_PATH.fullmatch(path): + return "immutable" + if re.fullmatch(r"keys/(?:[A-F0-9]{40}|[A-F0-9]{64})\.asc", path): + return "immutable" + if REPODATA_PATH.fullmatch(path): + return "immutable" + if path in ("repodata/repomd.xml", "repodata/repomd.xml.asc"): + return "metadata" + raise PublicationError("inventory contains a path outside the Fedora RPM protocol") + + +def plain_path(path, directory=False, missing=False): + """Reject symlinks in every existing ancestor, including origin roots.""" + path = Path(path).absolute() + require(".." not in path.parts, "paths must not contain parent traversal") + for item in reversed((path, *path.parents)): + try: + mode = item.lstat().st_mode + except FileNotFoundError: + if missing: + continue + raise PublicationError("required path does not exist") from None + require(not stat.S_ISLNK(mode), "symlinks are forbidden in repository paths") + if item != path or directory: + require(stat.S_ISDIR(mode), "repository ancestor is not a directory") + return path + + +def tree_files(root): + root = plain_path(root, directory=True) + result = set() + for directory, dirs, files in os.walk(root, followlinks=False): + for name in dirs + files: + item = Path(directory) / name + info = item.lstat() + require(not stat.S_ISLNK(info.st_mode), "candidate contains a symlink") + if name in dirs: + require(stat.S_ISDIR(info.st_mode), "candidate contains a non-directory") + else: + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, + "candidate contains a nonregular file or hardlink") + result.add(item.relative_to(root).as_posix()) + return result + + +def read_json(path): + plain_path(path) + try: + with path.open(encoding="utf-8") as source: + return json.load(source, object_pairs_hook=object_pairs) + except (ValueError, UnicodeError) as error: + raise PublicationError("invalid repository JSON") from error + + +def inventory(root, fingerprint): + root = plain_path(root, directory=True) + actual = tree_files(root) + require(MANIFEST in actual, "candidate is missing its manifest") + manifest = read_json(root / MANIFEST) + require(isinstance(manifest, dict) and manifest.get("schema") == SCHEMA + and manifest.get("schemaVersion") == 1, "unsupported repository manifest") + require(set(manifest) == MANIFEST_FIELDS, "invalid repository manifest fields") + revision = manifest.get("revision") + require(isinstance(revision, str) and REVISION.fullmatch(revision), + "invalid candidate revision") + unsigned = {key: value for key, value in manifest.items() if key != "revision"} + require(hashlib.sha256(canonical(unsigned)).hexdigest() == revision, + "candidate revision does not match its manifest") + require(isinstance(fingerprint, str) and FINGERPRINT.fullmatch(fingerprint) + and manifest.get("signingFingerprint") == fingerprint, + "candidate signing fingerprint differs") + require(manifest.get("target") == TARGET, + "candidate must target Fedora 44 x86_64") + require(type(manifest.get("createdAt")) is int and type(manifest.get("validUntil")) is int + and manifest["validUntil"] > manifest["createdAt"], + "candidate validity interval is invalid") + require(isinstance(manifest.get("packages"), list) and manifest["packages"], + "candidate package inventory must be non-empty") + require(isinstance(manifest.get("files"), list), + "candidate file inventory must be a list") + expected = {MANIFEST} + indexed = {} + for entry in manifest["files"]: + require(isinstance(entry, dict), "invalid candidate file entry") + require(set(entry) == FILE_FIELDS, "invalid candidate file fields") + path = entry.get("path") + kind = classify(path) + require(path not in expected and entry.get("kind") == kind, + "duplicate file or incorrect inventory kind") + require(type(entry.get("size")) is int and entry["size"] >= 0, + "invalid inventory file size") + require(isinstance(entry.get("sha256"), str) and REVISION.fullmatch(entry["sha256"]), + "invalid inventory digest") + target = root / path + require(path in actual and target.stat().st_size == entry["size"] + and digest(target) == entry["sha256"], + "candidate file checksum or size differs") + expected.add(path) + indexed[path] = entry + if path.startswith("repodata/") and kind == "immutable": + require(Path(path).name.startswith(entry["sha256"] + "-"), + "repodata content filename and checksum differ") + require(actual == expected, "candidate contains unlisted files") + require("repodata/repomd.xml" in indexed and "repodata/repomd.xml.asc" in indexed, + "candidate is missing signed repository metadata") + require(f"keys/{fingerprint}.asc" in indexed, + "candidate is missing its pinned public key") + require(all(isinstance(item, dict) and set(item) == PACKAGE_FIELDS + for item in manifest["packages"]), "invalid package record fields") + package_paths = {item.get("path") for item in manifest["packages"]} + require(package_paths and all(PACKAGE_PATH.fullmatch(path or "") for path in package_paths), + "candidate has an invalid package record") + require(len(package_paths) == len(manifest["packages"]), + "candidate has duplicate package records") + require(package_paths == {path for path in indexed if PACKAGE_PATH.fullmatch(path)}, + "package records and file inventory differ") + for package in manifest["packages"]: + require(package["name"] == "loopwire" and package["release"] == "1.fc44" + and package["architecture"] == "x86_64" + and re.fullmatch(VERSION, package["version"]) + and package["path"] == ( + f"packages/loopwire-{package['version']}-1.fc44.x86_64.rpm" + ), "candidate package identity is invalid") + require(isinstance(package["sourceReleaseSha256"], str) + and REVISION.fullmatch(package["sourceReleaseSha256"]) + and isinstance(package["distributedSha256"], str) + and REVISION.fullmatch(package["distributedSha256"]) + and type(package["size"]) is int and package["size"] >= 0, + "candidate package hash or size is invalid") + entry = indexed[package["path"]] + require(entry["sha256"] == package["distributedSha256"] + and entry["size"] == package["size"], + "package record and file inventory differ") + return manifest + + +def verify_signed(root, key, fingerprint, historical=False): + manifest = inventory(root, fingerprint) + verifier = Path(__file__).resolve().with_name("rpm-repository.py") + require(verifier.is_file(), "rpm-repository.py verifier is missing") + command = [sys.executable, str(verifier), "verify", "--repository", str(root), + "--public-key", str(key), "--fingerprint", fingerprint] + if historical: + command += ["--now", str(manifest["createdAt"])] + result = subprocess.run(command, capture_output=True, text=True, check=False) + require(result.returncode == 0, + "signed RPM repository verification failed; run rpm-repository.py verify for diagnostics") + return manifest + + +def fsync_directory(path): + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def make_directory(path, mode=0o755): + path = plain_path(path, directory=True, missing=True) + if path.exists(): + return + make_directory(path.parent, mode=mode) + path.mkdir(mode=mode) + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fchmod(descriptor, mode) + os.fsync(descriptor) + finally: + os.close(descriptor) + fsync_directory(path.parent) + + +def atomic_write(target, source=None, data=None, mode=0o644, directory_mode=0o755): + plain_path(target, missing=True) + make_directory(target.parent, mode=directory_mode) + descriptor, temporary = tempfile.mkstemp(prefix=".upload-", dir=target.parent) + try: + with os.fdopen(descriptor, "wb") as output: + if source is not None: + with source.open("rb") as incoming: + shutil.copyfileobj(incoming, output, 1024 * 1024) + else: + output.write(data) + output.flush() + os.fchmod(output.fileno(), mode) + os.fsync(output.fileno()) + os.replace(temporary, target) + fsync_directory(target.parent) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def root_path(value): + path = Path(value) + require(path.is_absolute() and path != Path("/"), + "--root must be an absolute, non-root directory") + return plain_path(path, directory=True, missing=True) + + +def public_channel(root): + return root / "public" / PUBLIC_PREFIX + + +@contextlib.contextmanager +def locked(root, create=False): + if create: + make_directory(root) + if not root.exists(): + raise EmptyRepository("repository has no committed snapshot") + lock = root / ".publish.lock" + plain_path(lock, missing=True) + if not create and not lock.exists(): + require(not (root / "state").exists() and not public_channel(root).exists(), + "repository state exists without its publication lock") + raise EmptyRepository("repository has no committed snapshot") + flags = os.O_NOFOLLOW | (os.O_RDWR | os.O_CREAT if create else os.O_RDONLY) + descriptor = os.open(lock, flags, 0o600) + try: + info = os.fstat(descriptor) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, + "invalid publication lock file") + try: + fcntl.flock(descriptor, + (fcntl.LOCK_EX if create else fcntl.LOCK_SH) | fcntl.LOCK_NB) + except BlockingIOError: + raise PublicationError("repository is locked by another operation; retry later") from None + yield + finally: + os.close(descriptor) + + +def state(root, name): + path = root / "state" / f"{name}.json" + plain_path(path, missing=True) + if not path.exists(): + return None + information = path.stat() + require(stat.S_ISREG(information.st_mode) and information.st_nlink == 1, + "invalid publication state file") + record = read_json(path) + require(isinstance(record, dict) and isinstance(record.get("revision"), str) + and REVISION.fullmatch(record["revision"]), "invalid publication state") + if name == "current": + require(set(record) == {"revision"}, "invalid current publication state") + elif name == "pending": + previous = record.get("previousRevision") + require(set(record) == {"revision", "previousRevision"} + and (previous == "empty" or isinstance(previous, str) + and REVISION.fullmatch(previous)), + "invalid pending publication state") + return record + + +def _checkpoint(label): + """No-op hook for process-interruption tests; never environment-controlled.""" + + +def check_public(root, manifest, immutable_only=False): + channel = public_channel(root) + for entry in manifest["files"]: + if immutable_only and entry["kind"] != "immutable": + continue + target = channel / entry["path"] + plain_path(target, missing=True) + if target.exists(): + info = target.stat() + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, + "public target is not a standalone regular file") + require(info.st_size == entry["size"] and digest(target) == entry["sha256"], + "immutable URL collision" if immutable_only + else "committed public repository has drifted") + elif not immutable_only: + raise PublicationError("committed public repository is missing files") + if not immutable_only: + public_manifest = channel / MANIFEST + plain_path(public_manifest, missing=True) + require(public_manifest.is_file() and public_manifest.stat().st_nlink == 1 + and read_json(public_manifest) == manifest, + "committed public repository manifest has drifted") + + +def save_snapshot(root, repository, manifest): + snapshots = root / "snapshots" + make_directory(snapshots, mode=0o700) + snapshot = snapshots / manifest["revision"] + plain_path(snapshot, directory=True, missing=True) + if snapshot.exists(): + require(inventory(snapshot, manifest["signingFingerprint"]) == manifest, + "retained snapshot differs from candidate") + return snapshot + temporary = Path(tempfile.mkdtemp(prefix=".staging-", dir=snapshots)) + try: + os.chmod(temporary, 0o700) + for entry in manifest["files"]: + atomic_write(temporary / entry["path"], source=repository / entry["path"], + mode=0o600, directory_mode=0o700) + atomic_write(temporary / MANIFEST, source=repository / MANIFEST, + mode=0o600, directory_mode=0o700) + inventory(temporary, manifest["signingFingerprint"]) + fsync_directory(temporary) + os.replace(temporary, snapshot) + fsync_directory(snapshots) + finally: + if temporary.exists(): + shutil.rmtree(temporary) + return snapshot + + +def promote(root, snapshot, manifest): + """Publish immutable data, signature, then atomic repomd.xml commit.""" + channel = public_channel(root) + make_directory(channel) + devices = {path.stat().st_dev for path in + (root, channel, root / "state", root / "snapshots")} + require(len(devices) == 1, + "origin public, snapshot, and state paths must share one filesystem") + check_public(root, manifest, immutable_only=True) + for entry in manifest["files"]: + if entry["kind"] == "immutable": + target = channel / entry["path"] + if not target.exists(): + atomic_write(target, source=snapshot / entry["path"]) + _checkpoint("immutable") + signature = "repodata/repomd.xml.asc" + atomic_write(channel / signature, source=snapshot / signature) + _checkpoint("signature") + metadata = "repodata/repomd.xml" + atomic_write(channel / metadata, source=snapshot / metadata) + _checkpoint("committed") + atomic_write(channel / MANIFEST, source=snapshot / MANIFEST) + check_public(root, manifest) + _checkpoint("manifest") + atomic_write(root / "state" / "current.json", + data=canonical({"revision": manifest["revision"]}) + b"\n", mode=0o600) + _checkpoint("current") + (root / "state" / "pending.json").unlink() + fsync_directory(root / "state") + return {"status": "published", "revision": manifest["revision"], + "target": TARGET.copy()} + + +def publish_at(root, repository, fingerprint, expected): + manifest = inventory(repository, fingerprint) + with locked(root, create=True): + current = state(root, "current") + pending = state(root, "pending") + revision = current["revision"] if current else "empty" + if pending: + require(pending["revision"] == manifest["revision"], + "interrupted publication pending; recover it before publishing another revision") + require(expected == pending.get("previousRevision"), + "expected revision differs from interrupted publication") + require(revision in (pending["previousRevision"], pending["revision"]), + "current revision conflicts with pending journal") + snapshot = root / "snapshots" / pending["revision"] + require(inventory(snapshot, fingerprint) == manifest, + "pending snapshot differs from candidate") + return promote(root, snapshot, manifest) + if revision == manifest["revision"]: + check_public(root, manifest) + return {"status": "unchanged", "revision": revision, + "target": TARGET.copy()} + require(expected == revision, + "expected revision differs from current publication (compare-and-swap failed)") + if current is None: + channel = public_channel(root) + plain_path(channel, directory=True, missing=True) + require(not channel.exists() or not any(channel.iterdir()), + "refusing to adopt an unmanaged public Fedora repository") + check_public(root, manifest, immutable_only=True) + snapshot = save_snapshot(root, repository, manifest) + make_directory(root / "state", mode=0o700) + atomic_write(root / "state" / "pending.json", data=canonical({ + "revision": manifest["revision"], "previousRevision": revision, + }) + b"\n", mode=0o600) + _checkpoint("journal") + return promote(root, snapshot, manifest) + + +def recover_at(root, fingerprint, revision): + with locked(root, create=True): + pending = state(root, "pending") + require(pending is not None and pending["revision"] == revision, + "pending publication changed; fetch and verify it again before recovery") + current = state(root, "current") + require((current["revision"] if current else "empty") + in (pending.get("previousRevision"), revision), + "current revision conflicts with pending journal") + snapshot = root / "snapshots" / revision + return promote(root, snapshot, inventory(snapshot, fingerprint)) + + +@contextlib.contextmanager +def selected_snapshot(root, fingerprint, revision=None, pending_only=False): + with locked(root): + pending = state(root, "pending") + if pending_only: + if not pending: + raise EmptyRepository("repository has no pending publication") + revision = pending["revision"] + else: + require(pending is None, + "interrupted publication pending; recover before fetching snapshots") + if revision is None: + current = state(root, "current") + if not current: + raise EmptyRepository("repository has no committed snapshot") + revision = current["revision"] + snapshot = root / "snapshots" / revision + manifest = inventory(snapshot, fingerprint) + require(manifest["revision"] == revision, + "snapshot does not match selected revision") + yield snapshot, manifest + + +def write_archive(repository, output): + with tarfile.open(fileobj=output, mode="w|") as archive: + for relative in sorted(tree_files(repository)): + archive.add(repository / relative, arcname=relative, recursive=False) + + +def read_archive(source, output): + seen = set() + with tarfile.open(fileobj=source, mode="r|*") as archive: + for member in archive: + safe_path(member.name) + require(member.name == MANIFEST or classify(member.name), + "unexpected archive path") + require(member.isfile() and not member.issym() and not member.islnk() + and member.name not in seen, + "archive contains a link, special file, or duplicate") + seen.add(member.name) + target = output / member.name + make_directory(target.parent) + with archive.extractfile(member) as incoming, target.open("xb") as destination: + shutil.copyfileobj(incoming, destination, 1024 * 1024) + + +def ssh_command(args, request): + require(re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]*@[A-Za-z0-9][A-Za-z0-9_.-]*", args.ssh), + "--ssh must be USER@HOST using a hostname or IPv4 address") + require(args.known_hosts is not None and args.identity_file is not None, + "remote operations require --known-hosts and --identity-file") + known_hosts = plain_path(args.known_hosts) + identity = plain_path(args.identity_file) + require(known_hosts.is_file() and known_hosts.stat().st_nlink == 1, + "--known-hosts must name a regular file") + require(identity.is_file() and identity.stat().st_nlink == 1, + "--identity-file must name a regular file") + command = ["ssh", "-F", "/dev/null", "-T", "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=yes", + "-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no", + "-o", "ForwardAgent=no", "-o", "ClearAllForwardings=yes", + "-o", "ConnectTimeout=15", "-o", f"UserKnownHostsFile={known_hosts}", + "-o", "GlobalKnownHostsFile=/dev/null", "-i", str(identity), + "-o", "IdentitiesOnly=yes"] + if args.ssh_port: + command += ["-p", str(args.ssh_port)] + encoded = base64.urlsafe_b64encode(canonical(request)).decode("ascii") + source = Path(__file__).read_text(encoding="utf-8") + remote = "python3 -c " + shlex.quote(source) + " _serve " + shlex.quote(encoded) + return command + ["--", args.ssh, remote] + + +def remote_call(args, request, repository=None, output=None): + command = ssh_command(args, request) + with tempfile.TemporaryFile() as incoming, tempfile.TemporaryFile() as outgoing: + if repository: + write_archive(repository, incoming) + incoming.seek(0) + result = subprocess.run(command, stdin=incoming, stdout=outgoing, + stderr=subprocess.PIPE, check=False) + if result.returncode == 3: + raise EmptyRepository("remote repository has no selected snapshot") + if result.returncode == 1: + try: + failure = json.loads(result.stderr) + if failure.get("status") == "error" and isinstance(failure.get("message"), str): + raise PublicationError(failure["message"]) + except (ValueError, AttributeError): + pass + require(result.returncode == 0, + "SSH repository operation failed; check pinned host key, identity, connectivity, remote Python, and publisher state") + outgoing.seek(0) + if output: + read_archive(outgoing, output) + return None + try: + return json.load(outgoing) + except (ValueError, UnicodeError) as error: + raise PublicationError("SSH repository response is not valid JSON") from error + + +def serve(request): + root = root_path(request["root"]) + fingerprint = request["fingerprint"] + require(FINGERPRINT.fullmatch(fingerprint), "invalid signing fingerprint") + action = request["action"] + if action == "publish": + require(request["expected"] == "empty" or REVISION.fullmatch(request["expected"]), + "invalid expected revision") + with tempfile.TemporaryDirectory(prefix="loopwire-rpm-upload-") as directory: + repository = Path(directory) + read_archive(sys.stdin.buffer, repository) + return publish_at(root, repository, fingerprint, request["expected"]) + if action == "recover": + require(REVISION.fullmatch(request["revision"]), "invalid recovery revision") + return recover_at(root, fingerprint, request["revision"]) + require(action in ("fetch", "fetch-pending"), "unknown remote operation") + revision = request.get("revision") + require(revision is None or REVISION.fullmatch(revision), "invalid fetch revision") + with selected_snapshot(root, fingerprint, revision, + action == "fetch-pending") as (snapshot, _): + write_archive(snapshot, sys.stdout.buffer) + return None + + +def fetch_into(args, output, pending_only=False): + if args.ssh: + remote_call(args, { + "action": "fetch-pending" if pending_only else "fetch", + "root": args.root, + "fingerprint": args.fingerprint, + "revision": getattr(args, "revision", None), + }, output=output) + else: + with selected_snapshot(root_path(args.root), args.fingerprint, + getattr(args, "revision", None), + pending_only) as (snapshot, _): + shutil.copytree(snapshot, output, dirs_exist_ok=True) + historical = not pending_only or getattr(args, "allow_expired", False) + return verify_signed(output, args.public_key, args.fingerprint, historical=historical) + + +def parser(): + result = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + actions = result.add_subparsers(dest="action", required=True) + for name in ("publish", "fetch", "recover"): + action = actions.add_parser(name) + action.add_argument("--root", required=True, + help="absolute origin root; HTTP serves ROOT/public") + action.add_argument("--public-key", required=True, type=Path) + action.add_argument("--fingerprint", required=True) + action.add_argument("--ssh", metavar="USER@HOST", + help="omit for a local POSIX origin") + action.add_argument("--ssh-port", type=int) + action.add_argument("--identity-file", type=Path, + help="required SSH private identity for remote operations") + action.add_argument("--known-hosts", type=Path, + help="required pinned known_hosts for remote operations") + if name == "publish": + action.add_argument("--repository", type=Path, required=True) + action.add_argument("--expected-revision", required=True, + help="observed revision, or literal empty for first publication") + action.add_argument("--dry-run", action="store_true", + help="verify locally; perform no origin access or upload") + elif name == "fetch": + action.add_argument("--output", type=Path, required=True, + help="new destination directory (must not exist)") + action.add_argument("--revision", + help="retained snapshot revision; defaults to committed current") + else: + action.add_argument("--dry-run", action="store_true", + help="fetch and verify pending snapshot without promotion") + action.add_argument("--allow-expired", action="store_true", + help="finish an expired signed journal, then immediately publish fresh metadata") + return result + + +def run(args): + if args.ssh: + requested_root = Path(args.root) + require(requested_root.is_absolute() and args.root != "/" + and ".." not in requested_root.parts + and requested_root.as_posix() == args.root, + "--root must be an absolute normalized non-root path") + else: + root_path(args.root) + require(FINGERPRINT.fullmatch(args.fingerprint), + "--fingerprint must be a full uppercase fingerprint") + require(args.ssh_port is None or 1 <= args.ssh_port <= 65535, + "invalid SSH port") + require(args.ssh or (args.ssh_port is None and args.known_hosts is None + and args.identity_file is None), + "SSH options require --ssh") + if args.ssh: + require(args.known_hosts is not None and args.identity_file is not None, + "remote operations require --known-hosts and --identity-file") + args.public_key = plain_path(args.public_key) + require(args.public_key.is_file() and args.public_key.stat().st_nlink == 1, + "--public-key must name a regular file") + if args.action == "publish": + require(args.expected_revision == "empty" or REVISION.fullmatch(args.expected_revision), + "invalid expected revision") + manifest = verify_signed(args.repository, args.public_key, args.fingerprint) + if args.dry_run: + return {"status": "validated", "revision": manifest["revision"], + "originChecked": False} + with tempfile.TemporaryDirectory(prefix="loopwire-rpm-candidate-") as directory: + candidate = Path(directory) / "repository" + shutil.copytree(args.repository, candidate, symlinks=True) + require(verify_signed(candidate, args.public_key, args.fingerprint) == manifest, + "candidate changed during verification") + if args.ssh: + return remote_call(args, { + "action": "publish", "root": args.root, + "fingerprint": args.fingerprint, + "expected": args.expected_revision, + }, repository=candidate) + return publish_at(root_path(args.root), candidate, args.fingerprint, + args.expected_revision) + if args.action == "fetch": + require(args.revision is None or REVISION.fullmatch(args.revision), + "invalid selected revision") + output = plain_path(args.output, directory=True, missing=True) + require(not output.exists(), "--output must not exist") + require(output.parent.is_dir(), "--output parent must already exist") + with tempfile.TemporaryDirectory(prefix=".loopwire-rpm-fetch-", + dir=output.parent) as directory: + fetched = Path(directory) / "repository" + fetched.mkdir() + manifest = fetch_into(args, fetched) + os.rename(fetched, output) + fsync_directory(output.parent) + return {"status": "fetched", "revision": manifest["revision"]} + with tempfile.TemporaryDirectory(prefix="loopwire-rpm-recovery-") as directory: + manifest = fetch_into(args, Path(directory), pending_only=True) + needs_refresh = manifest["validUntil"] <= int(time.time()) + if args.dry_run: + return {"status": "recovery-validated", "revision": manifest["revision"], + "requiresRefresh": needs_refresh} + if args.ssh: + result = remote_call(args, { + "action": "recover", "root": args.root, + "fingerprint": args.fingerprint, "revision": manifest["revision"], + }) + else: + result = recover_at(root_path(args.root), args.fingerprint, + manifest["revision"]) + result["requiresRefresh"] = needs_refresh + if needs_refresh: + result["nextAction"] = ( + "Immediately fetch, rebuild, sign, and publish fresh metadata; " + "the project verifier rejects the expired snapshot. DNF signature checks do not enforce this project deadline." + ) + return result + + +def main(): + try: + if len(sys.argv) == 3 and sys.argv[1] == "_serve": + result = serve(json.loads(base64.urlsafe_b64decode(sys.argv[2]))) + else: + result = run(parser().parse_args()) + if result is not None: + print(json.dumps(result, sort_keys=True)) + return 0 + except EmptyRepository: + print(json.dumps({"status": "empty", "revision": None})) + return 3 + except (PublicationError, OSError, ValueError, KeyError, TypeError, + tarfile.TarError) as error: + message = (str(error) if isinstance(error, PublicationError) + else "repository operation failed; check filesystem and inputs") + print(json.dumps({"status": "error", "message": message}), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/rpm-repository.py b/scripts/rpm-repository.py new file mode 100644 index 0000000..3ef1fa9 --- /dev/null +++ b/scripts/rpm-repository.py @@ -0,0 +1,776 @@ +#!/usr/bin/env python3 +"""Build and verify immutable signed Loopwire Fedora repository candidates. + +Requires Python's standard library, createrepo_c, rpm, rpmkeys, rpmsign, +gpg, gpgv, and openssl. The publisher retains immutable package and metadata +objects globally, writes repomd.xml.asc first, and atomically replaces +repomd.xml as the public commit point. This module never publishes files or +changes host repository configuration. + +The explicit --date fixes repository timestamps and GnuPG signature creation +times. Byte-for-byte repeatability still requires the pinned Fedora tool image, +the same key material, and a deterministic OpenPGP algorithm; a prior package +with identical source bytes is reused rather than signed again. +""" + +import argparse +import gzip +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shlex +import shutil +import stat +import subprocess +import sys +import tempfile +import time +import xml.etree.ElementTree as ET + + +SCHEMA = "loopwire.rpm-repository.v1" +MANIFEST = "repository-manifest.json" +TARGET = {"distribution": "fedora", "release": "44", "architecture": "x86_64"} +VERSION = r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?" +HASH = r"[0-9a-f]{64}" +FINGERPRINT = r"(?:[0-9A-F]{40}|[0-9A-F]{64})" +PACKAGE_FIELDS = { + "name", "version", "release", "architecture", "path", + "sourceReleaseSha256", "distributedSha256", "size", +} +FILE_FIELDS = {"path", "sha256", "size", "kind"} +ROOT = Path(__file__).resolve().parent.parent +REPO_NS = "http://linux.duke.edu/metadata/repo" +COMMON_NS = "http://linux.duke.edu/metadata/common" + + +class RepositoryError(Exception): + """An invalid or unauthenticated RPM repository candidate.""" + + +def require(condition, message): + if not condition: + raise RepositoryError(message) + + +def run(*args, cwd=None, env=None): + try: + result = subprocess.run( + [str(value) for value in args], cwd=cwd, env=env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, + ) + except FileNotFoundError as error: + raise RepositoryError(f"required tool is missing: {args[0]}") from error + require( + result.returncode == 0, + f"{args[0]} failed: {result.stderr.decode('utf-8', errors='replace').strip()}", + ) + return result.stdout + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def sha256(path): + with path.open("rb") as stream: + return hashlib.file_digest(stream, "sha256").hexdigest() + + +def fingerprint(value): + require( + isinstance(value, str) and re.fullmatch(FINGERPRINT, value) is not None, + "fingerprint must be the complete uppercase OpenPGP fingerprint", + ) + return value + + +def hash_value(value, label): + require(isinstance(value, str) and re.fullmatch(HASH, value) is not None, f"invalid {label}") + return value + + +def integer(value, label, minimum=0): + require(type(value) is int and value >= minimum, f"invalid {label}") + return value + + +def exact_keys(value, expected, label): + require(isinstance(value, dict) and set(value) == set(expected), f"invalid {label} fields") + + +def object_pairs(pairs): + result = {} + for key, value in pairs: + require(key not in result, f"duplicate JSON field: {key}") + result[key] = value + return result + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=object_pairs) + + +def safe_path(value): + require( + isinstance(value, str) and value + and not any(ord(character) < 33 or ord(character) > 126 for character in value), + "inventory paths must contain printable ASCII without whitespace", + ) + path = PurePosixPath(value) + require( + not path.is_absolute() and str(path) == value and ".." not in path.parts and "\\" not in value, + f"unsafe inventory path: {value}", + ) + return value + + +def classify_path(value): + safe_path(value) + if re.fullmatch(rf"keys/{FINGERPRINT}\.asc", value): + return "immutable" + if re.fullmatch(rf"packages/loopwire-{VERSION}-1\.fc44\.x86_64\.rpm", value): + return "immutable" + if re.fullmatch(rf"repodata/{HASH}-(?:primary|filelists|other)\.xml\.gz", value): + return "immutable" + if value in ("repodata/repomd.xml", "repodata/repomd.xml.asc"): + return "metadata" + raise RepositoryError(f"path is outside the Fedora repository contract: {value}") + + +def regular_file(path): + information = path.lstat() + require( + stat.S_ISREG(information.st_mode) and information.st_nlink == 1, + f"only regular files without symlinks or hardlinks are allowed: {path}", + ) + return information + + +def real_directory(path, label): + require(path.is_dir() and not path.is_symlink(), f"{label} must be a real directory") + return path + + +def tree_files(root): + real_directory(root, "repository") + files = set() + for directory, directories, names in os.walk(root, followlinks=False): + for name in directories: + path = Path(directory) / name + require(not path.is_symlink(), f"symlink directory is forbidden: {path}") + for name in names: + path = Path(directory) / name + regular_file(path) + files.add(path.relative_to(root).as_posix()) + return files + + +def key_fingerprint(key, home): + data = run( + "gpg", "--batch", "--homedir", home, "--with-colons", + "--import-options", "show-only", "--import", key, + ).decode("utf-8") + primary = [] + want_fingerprint = False + for line in data.splitlines(): + fields = line.split(":") + if fields[0] == "pub": + want_fingerprint = True + elif fields[0] == "fpr" and want_fingerprint: + primary.append(fingerprint(fields[9])) + want_fingerprint = False + elif fields[0] == "sub": + want_fingerprint = False + require(len(primary) == 1, "public key must contain exactly one primary OpenPGP key") + return primary[0] + + +def manifest_revision(manifest): + unsigned = {key: value for key, value in manifest.items() if key != "revision"} + return hashlib.sha256(canonical(unsigned)).hexdigest() + + +def rpm_identity(path): + regular_file(path) + fields = run( + "rpm", "-qp", "--queryformat", + "%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n%{EPOCHNUM}\n", path, + ).decode("utf-8").splitlines() + require(len(fields) == 5, "RPM identity query returned unexpected fields") + name, version, release, architecture, epoch = fields + require(name == "loopwire", "repository only accepts RPM Name: loopwire") + require(re.fullmatch(VERSION, version) is not None, f"unsupported RPM version: {version}") + require(release == "1.fc44", f"repository only accepts RPM Release: 1.fc44, got {release}") + require(architecture == "x86_64", f"repository only accepts RPM Architecture: x86_64, got {architecture}") + require(epoch in ("0", "(none)"), "repository RPM must not set a nonzero epoch") + return name, version, release, architecture + + +def rpm_has_signature(path): + output = run( + "rpm", "-qp", "--queryformat", + "%{OPENPGP:pgpsig}\n%{SIGPGP:pgpsig}\n%{SIGGPG:pgpsig}\n" + "%{RSAHEADER:pgpsig}\n%{DSAHEADER:pgpsig}\n", path, + ).decode("utf-8", errors="replace") + return any(value.strip() not in ("", "(none)") for value in output.splitlines()) + + +def verify_rpm_digest(path): + run("rpmkeys", "--nosignature", "--checksig", path) + + +def verify_rpm_signature(path, public_key): + require(rpm_has_signature(path), f"RPM has no OpenPGP package signature: {path}") + with tempfile.TemporaryDirectory(prefix="loopwire-rpm-keyring-") as temporary: + database = Path(temporary) / "rpmdb" + database.mkdir() + run("rpmkeys", "--dbpath", database, "--import", public_key) + run("rpmkeys", "--dbpath", database, "--checksig", path) + + +def package_info(root, path, source_hash, public_key): + classify_path(path) + require(path.startswith("packages/"), "RPM path is outside packages/") + package = root / path + verify_rpm_digest(package) + verify_rpm_signature(package, public_key) + name, version, release, architecture = rpm_identity(package) + require( + Path(path).name == f"{name}-{version}-{release}.{architecture}.rpm", + "RPM filename does not match its NEVRA identity", + ) + return { + "name": name, + "version": version, + "release": release, + "architecture": architecture, + "path": path, + "sourceReleaseSha256": hash_value(source_hash, "source release SHA256"), + "distributedSha256": sha256(package), + "size": package.stat().st_size, + } + + +def verify_detached_signature(data, signature, keyring, home, expected): + armored = signature.read_text(encoding="ascii") + require( + armored.startswith("-----BEGIN PGP SIGNATURE-----\n") + and armored.rstrip().endswith("-----END PGP SIGNATURE-----"), + "repomd.xml signature must be detached ASCII armor", + ) + status = run( + "gpgv", "--homedir", home, "--keyring", keyring, + "--status-fd", "1", signature, data, + ).decode("utf-8") + valid = [line.split() for line in status.splitlines() if line.startswith("[GNUPG:] VALIDSIG ")] + require( + len(valid) == 1 and valid[0][-1] == expected, + "repomd.xml signature does not match the pinned primary fingerprint", + ) + require( + not any(f"[GNUPG:] {flag}" in status for flag in ( + "EXPKEYSIG", "EXPSIG", "REVKEYSIG", "KEYREVOKED", "KEYEXPIRED", "SIGEXPIRED", + )), + "repomd.xml uses an expired or revoked signing identity", + ) + + +def load_inventory(root): + actual = tree_files(root) + require(MANIFEST in actual, "missing repository-manifest.json") + manifest = read_json(root / MANIFEST) + exact_keys( + manifest, + {"schema", "schemaVersion", "revision", "signingFingerprint", "createdAt", + "validUntil", "target", "packages", "files"}, + "repository manifest", + ) + require(manifest["schema"] == SCHEMA, "unsupported repository manifest schema") + require(type(manifest["schemaVersion"]) is int and manifest["schemaVersion"] == 1, + "unsupported repository manifest schema version") + fingerprint(manifest["signingFingerprint"]) + integer(manifest["createdAt"], "createdAt") + integer(manifest["validUntil"], "validUntil") + require(manifest["validUntil"] > manifest["createdAt"], "metadata expiry must follow creation") + exact_keys(manifest["target"], TARGET, "repository target") + require(manifest["target"] == TARGET, "repository target must be Fedora 44 x86_64") + require(manifest["revision"] == manifest_revision(manifest), "manifest revision digest mismatch") + require(isinstance(manifest["packages"], list) and manifest["packages"], "packages must be a nonempty array") + require(isinstance(manifest["files"], list), "files must be an array") + inventory = {} + for entry in manifest["files"]: + exact_keys(entry, FILE_FIELDS, "inventory entry") + path = safe_path(entry["path"]) + require(path not in inventory, f"duplicate inventory path: {path}") + require(entry["kind"] == classify_path(path), f"incorrect file classification: {path}") + integer(entry["size"], "file size") + hash_value(entry["sha256"], f"SHA256 for {path}") + require(path in actual, f"missing inventory file: {path}") + file = root / path + require( + file.stat().st_size == entry["size"] and sha256(file) == entry["sha256"], + f"inventory checksum mismatch: {path}", + ) + if path.startswith("repodata/") and entry["kind"] == "immutable": + require(Path(path).name.startswith(entry["sha256"] + "-"), + f"repodata content filename/checksum mismatch: {path}") + inventory[path] = entry + require(actual == set(inventory) | {MANIFEST}, "repository has unlisted files") + return manifest, inventory + + +def xml(root, name): + values = root.findall(f"{{{REPO_NS}}}{name}") + require(len(values) == 1, f"repomd.xml must contain exactly one {name}") + return values[0] + + +def parse_repomd(root, manifest, inventory): + path = root / "repodata/repomd.xml" + raw = path.read_bytes() + require(b" now, "repository metadata has expired; refresh and re-sign it") + regular_file(public_key) + with tempfile.TemporaryDirectory(prefix="loopwire-rpm-verify-") as temporary: + home = Path(temporary) + require(key_fingerprint(public_key, home) == expected, "trusted public key differs from operator pin") + trusted_ring = home / "trusted.gpg" + run("gpg", "--batch", "--homedir", home, "--dearmor", "--output", trusted_ring, public_key) + key_path = f"keys/{expected}.asc" + require(key_path in inventory, "missing fingerprint-addressed repository key") + exported_key = root / key_path + require(key_fingerprint(exported_key, home) == expected, "exported key fingerprint/path mismatch") + exported_ring = home / "exported.gpg" + run("gpg", "--batch", "--homedir", home, "--dearmor", "--output", exported_ring, exported_key) + repomd = root / "repodata/repomd.xml" + signature = root / "repodata/repomd.xml.asc" + require("repodata/repomd.xml" in inventory and "repodata/repomd.xml.asc" in inventory, + "repository is missing signed repomd metadata") + verify_detached_signature(repomd, signature, trusted_ring, home, expected) + verify_detached_signature(repomd, signature, exported_ring, home, expected) + metadata, source_tags = parse_repomd(root, manifest, inventory) + indexed = primary_packages(metadata["primary"]) + require(len(indexed) == len(manifest["packages"]), "manifest/primary package count mismatch") + packages = {} + versions = set() + for entry in manifest["packages"]: + exact_keys(entry, PACKAGE_FIELDS, "package inventory entry") + path = safe_path(entry["path"]) + require(path not in packages and path in inventory and path in source_tags, + "duplicate or missing package inventory path") + require(inventory[path]["kind"] == "immutable", "RPM package must be immutable") + info = package_info(root, path, source_tags[path], public_key) + verify_rpm_signature(root / path, exported_key) + require(entry == info, f"RPM identity/hash differs from package inventory: {path}") + require(inventory[path]["sha256"] == info["distributedSha256"] + and inventory[path]["size"] == info["size"], f"file inventory differs for RPM: {path}") + require(info["version"] not in versions, "duplicate RPM version in repository") + versions.add(info["version"]) + packages[path] = info + require(set(source_tags) == set(packages), "signed source provenance does not match package inventory") + require(set(indexed) == set(packages), "primary metadata package set differs from manifest") + for path, package in packages.items(): + record = indexed[path] + require( + record == { + "name": package["name"], "version": package["version"], + "release": package["release"], "architecture": package["architecture"], + "epoch": "0", "sha256": package["distributedSha256"], "size": package["size"], + }, + f"signed primary metadata differs from RPM package: {path}", + ) + return manifest + + +def export_signer(args, directory): + expected = fingerprint(args.signing_key) + home = real_directory(args.gnupg_home, "GnuPG home") + if args.passphrase_file: + information = regular_file(args.passphrase_file) + require(information.st_mode & 0o077 == 0, "passphrase file must not be group/world accessible") + gpg = ["gpg", "--batch", "--no-tty", "--homedir", str(home)] + if args.passphrase_file: + gpg.extend(["--pinentry-mode", "loopback", "--passphrase-file", str(args.passphrase_file)]) + run(*gpg, "--list-secret-keys", expected) + key = directory / "signer.asc" + key.write_bytes(run(*gpg, "--export-options", "export-minimal", "--armor", "--export", expected)) + require(key.stat().st_size > 0, "signing public key is missing") + require(key_fingerprint(key, directory) == expected, "signing key must identify one primary key") + return gpg, key, expected + + +def signing_wrapper(directory, gpg_home, passphrase_file, date): + executable = shutil.which("gpg") + require(executable is not None, "required tool is missing: gpg") + arguments = [ + executable, "--batch", "--no-tty", "--pinentry-mode", "loopback", + "--homedir", str(gpg_home), "--faked-system-time", f"{date}!", + ] + if passphrase_file: + arguments.extend(["--passphrase-file", str(passphrase_file)]) + wrapper = directory / "rpm-gpg-wrapper" + wrapper.write_text("#!/bin/sh\nexec " + " ".join(shlex.quote(value) for value in arguments) + + ' "$@"\n', encoding="utf-8") + wrapper.chmod(0o700) + return wrapper + + +def sign_rpm(package, expected, gpg_home, passphrase_file, date, directory, public_key): + wrapper = signing_wrapper(directory, gpg_home, passphrase_file, date) + run( + "rpmsign", "--resign", + "--define", f"_openpgp_sign_id {expected}", + "--define", f"_gpg_path {gpg_home}", + "--define", f"__gpg {wrapper}", + package, + ) + verify_rpm_signature(package, public_key) + + +def rpm_version_compare(left, right): + require(re.fullmatch(VERSION, left) and re.fullmatch(VERSION, right), "invalid RPM version comparison") + expression = f"%{{lua:print(rpm.vercmp('{left}', '{right}'))}}" + value = run("rpm", "--eval", expression).decode("ascii").strip() + require(value in ("-1", "0", "1"), "rpm version comparison returned an invalid result") + return int(value) + + +def previous_repository(path, key, expected): + manifest, _inventory = load_inventory(path) + return verify_repository(path, key, expected, manifest["createdAt"]) + + +def copy_immutable(source, target, manifest): + for entry in manifest["files"]: + if entry["kind"] != "immutable": + continue + destination = target / entry["path"] + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / entry["path"], destination) + require( + sha256(destination) == entry["sha256"] and destination.stat().st_size == entry["size"], + "previous immutable file changed while copying the snapshot", + ) + + +def signed_release_package(args, working, packages, expected, key, staging, date): + require( + re.fullmatch(VERSION, args.version) is not None, + "Fedora publication requires X.Y.Z with optional +build metadata", + ) + release = real_directory(args.release_dir, "release directory") + checksums = release / "SHA256SUMS" + signature = release / "SHA256SUMS.sig" + regular_file(checksums) + regular_file(signature) + regular_file(args.release_public_key) + run( + "openssl", "dgst", "-sha256", "-verify", args.release_public_key, + "-signature", signature, checksums, + ) + signed = {} + for line in checksums.read_text(encoding="utf-8").splitlines(): + match = re.fullmatch(rf"({HASH}) [ *]([^/\\\s]+)", line) + require(match is not None, "invalid signed release checksum line") + checksum, name = match.groups() + require(name not in signed and name not in (".", ".."), "duplicate or invalid release checksum asset") + signed[name] = checksum + filename = f"loopwire-{args.version}-1.fc44.x86_64.rpm" + actual_rpms = {path.name for path in release.glob("*.rpm")} + known_release_rpms = {filename, f"loopwire-{args.version}-1.x86_64.rpm"} + require(filename in actual_rpms and actual_rpms <= known_release_rpms, + "release must contain the Fedora 44 RPM and no unknown RPM artifacts") + source = release / filename + regular_file(source) + source_hash = sha256(source) + require(filename in signed and signed[filename] == source_hash, + f"signed release checksum differs for {filename}") + verify_rpm_digest(source) + name, version, rpm_release, architecture = rpm_identity(source) + require(version == args.version, "RPM version differs from requested repository version") + path = f"packages/{filename}" + for previous in packages: + require(rpm_version_compare(version, previous["version"]) >= 0, + "new RPM version is lower than a published version; use explicit rollback") + matches = [entry for entry in packages if entry["version"] == version] + require(len(matches) <= 1, "previous repository has duplicate RPM versions") + if matches: + previous = matches[0] + require(previous["path"] == path and previous["sourceReleaseSha256"] == source_hash, + "same RPM version has different source release bytes") + require((working / path).is_file(), "retained RPM package is missing") + return + destination = working / path + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source, destination) + require(sha256(destination) == source_hash, "release RPM changed while copying to repository staging") + sign_rpm(destination, expected, args.gnupg_home, args.passphrase_file, date, staging, key) + packages.append(package_info(working, path, source_hash, key)) + packages.sort(key=lambda entry: entry["path"]) + + +def generate_metadata(working, packages, date, valid_until, gpg, expected, staging): + for package in packages: + path = working / package["path"] + regular_file(path) + os.utime(path, (date, date), follow_symlinks=False) + package_list = staging / "packages.list" + package_list.write_text("".join(entry["path"] + "\n" for entry in packages), encoding="utf-8") + generated = staging / "generated" + generated.mkdir() + repo_tags = [f"loopwire-valid-until:{valid_until}"] + repo_tags.extend( + f"loopwire-source-sha256:{entry['path']}:{entry['sourceReleaseSha256']}" + for entry in packages + ) + command = [ + "createrepo_c", "--quiet", "--no-database", "--checksum", "sha256", + "--repomd-checksum", "sha256", "--general-compress-type", "gz", + "--unique-md-filenames", "--workers", "1", "--changelog-limit", "0", + "--revision", str(date), "--set-timestamp-to-revision", + "--distro", "cpe:/o:fedoraproject:fedora:44,Fedora 44", + "--content", SCHEMA, "--pkglist", package_list, "--outputdir", generated, + ] + for tag in repo_tags: + command.extend(["--repo", tag]) + command.append(working) + run(*command) + generated_repodata = generated / "repodata" + real_directory(generated_repodata, "generated repodata") + destination = working / "repodata" + destination.mkdir(parents=True, exist_ok=True) + for path in sorted(generated_repodata.iterdir()): + regular_file(path) + relative = f"repodata/{path.name}" + classify_path(relative) + target = destination / path.name + if target.exists() and path.name != "repomd.xml": + require(sha256(target) == sha256(path), f"immutable repodata collision: {path.name}") + else: + shutil.copyfile(path, target) + repomd = destination / "repomd.xml" + signature = destination / "repomd.xml.asc" + run( + *gpg, "--yes", "--faked-system-time", f"{date}!", "--digest-algo", "SHA256", + "--local-user", expected, "--armor", "--detach-sign", "--output", signature, repomd, + ) + + +def write_candidate(args, rollback=False): + output = args.output + require( + not output.exists() and not output.is_symlink(), + "output must not already exist; reuse a completed candidate for publication retries", + ) + date = int(time.time()) if args.date is None else integer(args.date, "date") + require(1 <= args.valid_for_days <= 90, "valid-for-days must be between 1 and 90") + valid_until = date + args.valid_for_days * 86400 + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f".{output.name}-", dir=output.parent) as temporary: + staging = Path(temporary) + working = staging / "repository" + working.mkdir() + gpg, key, expected = export_signer(args, staging) + previous_path = args.repository if rollback else args.previous + previous = previous_repository(previous_path, key, expected) if previous_path else None + if previous: + require(date >= previous["createdAt"], "new metadata date must not precede the previous revision") + copy_immutable(previous_path, working, previous) + packages = json.loads(json.dumps(previous["packages"])) + else: + packages = [] + if not rollback: + signed_release_package(args, working, packages, expected, key, staging, date) + require(packages, "repository package set must not be empty") + exported = working / f"keys/{expected}.asc" + exported.parent.mkdir(parents=True, exist_ok=True) + if exported.exists(): + require(exported.read_bytes() == key.read_bytes(), + "fingerprint-addressed key bytes changed; rotate trust before changing exported packets") + else: + shutil.copyfile(key, exported) + generate_metadata(working, packages, date, valid_until, gpg, expected, staging) + files = [ + { + "path": path, "sha256": sha256(working / path), + "size": (working / path).stat().st_size, "kind": classify_path(path), + } + for path in sorted(tree_files(working)) + ] + manifest = { + "schema": SCHEMA, + "schemaVersion": 1, + "signingFingerprint": expected, + "createdAt": date, + "validUntil": valid_until, + "target": TARGET, + "packages": packages, + "files": files, + } + manifest["revision"] = manifest_revision(manifest) + (working / MANIFEST).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + verify_repository(working, key, expected, date) + working.rename(output) + return manifest + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + build = commands.add_parser("build", help="generate a Fedora candidate from signed native release assets") + build.add_argument("--release-dir", type=Path, required=True) + build.add_argument("--version", required=True) + build.add_argument( + "--release-public-key", type=Path, default=ROOT / "packaging/release-signing-public.pem", + help="trusted release checksum PEM (override for fixture keys)", + ) + build.add_argument("--previous", type=Path, help="verified previous snapshot whose immutable history is retained") + rollback = commands.add_parser("rollback", help="freshly sign a retained snapshot's previous package set") + rollback.add_argument("--repository", type=Path, required=True) + for command in (build, rollback): + command.add_argument("--output", type=Path, required=True) + command.add_argument("--signing-key", required=True, help="uppercase primary OpenPGP fingerprint") + command.add_argument("--gnupg-home", type=Path, required=True, + help="isolated GnuPG home containing the signing identity") + command.add_argument("--passphrase-file", type=Path, + help="protected file containing the signing-key passphrase; never pass the secret directly") + command.add_argument("--date", type=int, help="metadata/signature creation time as Unix epoch seconds") + command.add_argument("--valid-for-days", type=int, default=30) + verify = commands.add_parser("verify", help="verify the complete pinned Fedora repository trust chain") + verify.add_argument("--repository", type=Path, required=True) + verify.add_argument("--public-key", type=Path, required=True, + help="independently trusted ASCII-armored repository key") + verify.add_argument("--fingerprint", required=True, help="expected uppercase primary fingerprint") + verify.add_argument("--now", type=int, help="explicit verification time for fixtures or historical snapshots") + args = parser.parse_args() + if args.command == "verify": + manifest = verify_repository(args.repository, args.public_key, args.fingerprint, args.now) + else: + manifest = write_candidate(args, rollback=args.command == "rollback") + print(json.dumps({ + key: manifest[key] for key in + ("revision", "signingFingerprint", "createdAt", "validUntil", "target", "packages") + }, sort_keys=True)) + + +if __name__ == "__main__": + try: + main() + except (RepositoryError, ET.ParseError, OSError, ValueError, KeyError, TypeError, + EOFError, OverflowError, UnicodeError) as error: + print(f"rpm-repository: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/setup-fedora-repository.sh b/scripts/setup-fedora-repository.sh new file mode 100755 index 0000000..2009e52 --- /dev/null +++ b/scripts/setup-fedora-repository.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_url="" +fingerprint="" +install_root="/" +remove="false" +dry_run="false" + +fail() { printf 'setup-fedora-repository: %s\n' "$*" >&2; exit 1; } +usage() { + cat <<'USAGE' +Configure Loopwire's signed project repository on Fedora 44 x86_64. + +Usage: + sudo bash setup-fedora-repository.sh --base-url HTTPS_URL --fingerprint OPENPGP_FINGERPRINT + sudo bash setup-fedora-repository.sh --remove + bash setup-fedora-repository.sh --base-url HTTPS_URL --fingerprint FINGERPRINT --dry-run + +Options: + --root DIR Configure an offline filesystem tree instead of / (including its etc/os-release). + +Obtain the URL and fingerprint from the verified Loopwire channel documentation. +Requires curl, GnuPG, Python 3, and RPM. Existing unrelated DNF repositories are preserved. +This writes only the repository and key files. Run dnf makecache and dnf install yourself afterward. +USAGE +} +while [ "$#" -gt 0 ]; do + case "$1" in + --base-url) base_url="${2:?missing --base-url value}"; shift 2 ;; + --fingerprint) fingerprint="${2:?missing --fingerprint value}"; shift 2 ;; + --root) install_root="${2:?missing --root value}"; shift 2 ;; + --remove) remove="true"; shift ;; + --dry-run) dry_run="true"; shift ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +done + +install_root="$(realpath -e "$install_root")" +[ -d "$install_root" ] || fail "root must be an existing directory" +repo_file="${install_root%/}/etc/yum.repos.d/loopwire.repo" +key_directory="${install_root%/}/etc/pki/rpm-gpg" +python3 - "$install_root" "$repo_file" "$key_directory" <<'PY' +import sys +from pathlib import Path +root = Path(sys.argv[1]) +for name in sys.argv[2:]: + path = Path(name) + for part in (path, *path.parents): + if part == root: + break + if part.is_symlink(): + sys.exit('setup-fedora-repository: refusing symbolic links inside the target DNF configuration tree') + if not part.is_relative_to(root): + sys.exit('setup-fedora-repository: configuration path leaves the target root') +PY +owner_marker="# Managed by Loopwire Fedora repository setup" +[ ! -L "$repo_file" ] || fail "refusing a symbolic-link repository file" +if [ -e "$repo_file" ] && ! head -n 1 "$repo_file" | grep -Fxq "$owner_marker"; then + fail "loopwire.repo already exists and is not managed by this helper" +fi +if [ "$install_root" = / ] && [ "$dry_run" != true ] && [ "$EUID" -ne 0 ]; then + fail "run with sudo to change system repository configuration, or use --dry-run" +fi + +if [ "$remove" = true ]; then + if [ "$dry_run" = true ]; then + printf 'Would remove the managed Loopwire Fedora repository and key file.\n' + exit 0 + fi + if [ -f "$repo_file" ]; then + key_name="$(sed -n 's|^gpgkey=file:///etc/pki/rpm-gpg/\(RPM-GPG-KEY-loopwire-[A-F0-9]*\)$|\1|p' "$repo_file")" + [[ "$key_name" =~ ^RPM-GPG-KEY-loopwire-[A-F0-9]{40}$ ]] || fail "managed repository has an unexpected key path" + rm -- "$repo_file" + rm -f -- "$key_directory/$key_name" + fi + printf 'Loopwire Fedora repository removed. Installed packages, RPM database keys, and other repositories are unchanged.\n' + exit 0 +fi + +for command in curl gpg python3 rpm; do + command -v "$command" >/dev/null 2>&1 || fail "$command is required" +done +fingerprint="${fingerprint^^}" +[[ "$fingerprint" =~ ^[A-F0-9]{40}$ ]] || fail "provide a complete 40-character OpenPGP fingerprint" +base_url="$(python3 - "$base_url" <<'PY' +import sys +from urllib.parse import urlsplit +value = sys.argv[1] +try: + url = urlsplit(value) + valid = (url.scheme == 'https' and url.hostname and not url.username and not url.password + and not any(char in value for char in "\\'\"`$<>?#") + and all(32 < ord(char) < 127 for char in value)) + if not valid: + raise ValueError('invalid URL') + if url.port is not None and not 1 <= url.port <= 65535: + raise ValueError('invalid port') +except ValueError: + sys.exit('setup-fedora-repository: base URL must be HTTPS without credentials, whitespace, query, or fragment') +print(value.rstrip('/')) +PY +)" +python3 - "${install_root%/}/etc/os-release" <<'PY' +import shlex +import sys +from pathlib import Path +values = {} +for line in Path(sys.argv[1]).read_text().splitlines(): + if '=' in line and not line.lstrip().startswith('#'): + key, value = line.split('=', 1) + fields = shlex.split(value) + if len(fields) == 1: + values[key] = fields[0] +if (values.get('ID'), values.get('VERSION_ID')) != ('fedora', '44'): + sys.exit('setup-fedora-repository: supported system is Fedora 44') +PY +[ "$(rpm --eval '%{_arch}')" = x86_64 ] || fail "this channel currently supports x86_64 only" +key_name="RPM-GPG-KEY-loopwire-${fingerprint}" +[ ! -L "$key_directory/$key_name" ] || fail "refusing a symbolic-link key file" +if [ "$dry_run" = true ]; then + printf 'Would verify %s/keys/%s.asc and configure Fedora 44 x86_64 with RPM and metadata signature checks.\n' \ + "$base_url" "$fingerprint" + exit 0 +fi + +temporary="$(mktemp -d)" +key_temporary="" +repo_temporary="" +cleanup() { + rm -rf -- "$temporary" + [ -z "$key_temporary" ] || rm -f -- "$key_temporary" + [ -z "$repo_temporary" ] || rm -f -- "$repo_temporary" +} +trap cleanup EXIT +mkdir -m 0700 "$temporary/gnupg" +curl --disable --fail --silent --show-error --proto '=https' --tlsv1.2 \ + --connect-timeout 10 --max-time 60 --output "$temporary/key.asc" "$base_url/keys/$fingerprint.asc" +actual="$(gpg --no-options --batch --homedir "$temporary/gnupg" --with-colons --show-keys "$temporary/key.asc" | + awk -F: '$1 == "pub" { count++ } $1 == "fpr" && !seen { print $10; seen=1 } END { if (count != 1) exit 1 }')" +[ "$actual" = "$fingerprint" ] || fail "downloaded key does not match the expected fingerprint" +cat >"$temporary/loopwire.repo" < [%w[web], %w[deploy web]], 'apps/site/package.json' => [%w[web], %w[deploy web]], 'packaging/repositories/apt-channel.json' => [%w[web], %w[deploy web]], + 'packaging/repositories/fedora-channel.json' => [%w[web], %w[deploy web]], 'apps/docs/docs/guide/install.md' => [%w[web], %w[deploy web]], 'README.md' => [%w[web], %w[web]], 'packaging/README.md' => [%w[web], %w[web]], @@ -107,7 +108,7 @@ def selected_paths(path, event, parsed) check(parsed['deploy'].dig('jobs', 'deploy-bunny', 'environment') == 'docs-production', 'preserve deployment environment') condition = parsed['deploy'].dig('jobs', 'deploy-bunny', 'if') check(condition == condition.strip, 'deployment condition must not become an always-true string with trailing whitespace') -%w[release final-release-proof publish-aur publish-apt continuous-tests].each do |name| +%w[release final-release-proof publish-aur publish-apt publish-fedora continuous-tests].each do |name| workflow = YAML.safe_load_file(File.join(ROOT, '.github/workflows', "#{name}.yml")) events = workflow['on'] || workflow[true] check(!events.key?('pull_request'), "#{name}: deliberate operator workflows must not gain PR triggers") diff --git a/scripts/test-fedora-bootstrap.py b/scripts/test-fedora-bootstrap.py new file mode 100755 index 0000000..6f2fdfc --- /dev/null +++ b/scripts/test-fedora-bootstrap.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +import functools +import http.server +import os +from pathlib import Path +import shutil +import ssl +import subprocess +import tempfile +import threading +import unittest + + +SCRIPT = Path(__file__).with_name("setup-fedora-repository.sh") + + +def run(*args): + return subprocess.run(args, check=True, capture_output=True, text=True) + + +class BootstrapTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="loopwire-fedora-bootstrap-") + cls.work = Path(cls.temporary.name) + cls.home = cls.work / "gnupg" + cls.home.mkdir(mode=0o700) + run("gpg", "--batch", "--homedir", str(cls.home), "--pinentry-mode", "loopback", "--passphrase", "", + "--quick-generate-key", "Loopwire Fedora fixture ", "rsa2048", "sign", "1d") + listing = run("gpg", "--homedir", str(cls.home), "--with-colons", "--list-keys").stdout + cls.fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:")) + cls.public = run("gpg", "--homedir", str(cls.home), "--armor", "--export", cls.fingerprint).stdout + cls.web = cls.work / "web" + (cls.web / "keys").mkdir(parents=True) + (cls.web / "keys" / f"{cls.fingerprint}.asc").write_text(cls.public) + (cls.web / "keys" / f"{'A' * 40}.asc").write_text(cls.public) + cert, key = cls.work / "cert.pem", cls.work / "key.pem" + run("openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", "-subj", "/CN=127.0.0.1", + "-addext", "subjectAltName=IP:127.0.0.1", "-keyout", str(key), "-out", str(cert)) + cls.requests = [] + + class Handler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + cls.requests.append(self.path) + super().do_GET() + + cls.server = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), functools.partial(Handler, directory=str(cls.web))) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(cert, key) + cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True) + threading.Thread(target=cls.server.serve_forever, daemon=True).start() + cls.url = f"https://127.0.0.1:{cls.server.server_port}" + cls.binary = cls.work / "bin" + cls.binary.mkdir() + rpm = cls.binary / "rpm" + rpm.write_text('#!/bin/sh\nprintf "%s\\n" "${TEST_ARCH:-x86_64}"\n') + rpm.chmod(0o755) + cls.environment = {**os.environ, "PATH": f"{cls.binary}:{os.environ['PATH']}", "CURL_CA_BUNDLE": str(cert)} + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + subprocess.run(["gpgconf", "--homedir", str(cls.home), "--kill", "all"], check=False, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + cls.temporary.cleanup() + + def setUp(self): + self.root = self.work / "root" + shutil.rmtree(self.root, ignore_errors=True) + (self.root / "etc").mkdir(parents=True) + (self.root / "etc/os-release").write_text('ID=fedora\nVERSION_ID="44"\n') + self.repo = self.root / "etc/yum.repos.d/loopwire.repo" + self.key = self.root / f"etc/pki/rpm-gpg/RPM-GPG-KEY-loopwire-{self.fingerprint}" + self.requests.clear() + + def invoke(self, *args, fingerprint=None, url=None, env=None): + return subprocess.run([ + "bash", str(SCRIPT), "--root", str(self.root), "--base-url", url or self.url, + "--fingerprint", fingerprint or self.fingerprint, *args, + ], env={**self.environment, **(env or {})}, capture_output=True, text=True) + + def test_configuration_signature_checks_and_idempotence(self): + for _ in range(2): + result = self.invoke() + self.assertEqual(result.returncode, 0, result.stderr) + text = self.repo.read_text() + for line in [f"baseurl={self.url}", "gpgcheck=1", "repo_gpgcheck=1", "sslverify=1", + f"gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-loopwire-{self.fingerprint}"]: + self.assertIn(line, text) + self.assertEqual(self.key.read_text(), self.public) + self.assertEqual(self.key.stat().st_mode & 0o777, 0o644) + + def test_dry_run_has_no_network_or_writes(self): + result = self.invoke("--dry-run") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(self.requests, []) + self.assertFalse(self.repo.exists()) + + def test_wrong_fingerprint_preserves_existing_configuration(self): + self.assertEqual(self.invoke().returncode, 0) + before = self.repo.read_bytes() + result = self.invoke(fingerprint="A" * 40) + self.assertNotEqual(result.returncode, 0) + self.assertEqual(self.repo.read_bytes(), before) + + def test_bad_urls_and_wrong_platform_fail_before_network(self): + for url in ["http://example.invalid", "https://user:pass@example.invalid", "https://example.invalid/?", + "https://example.invalid/#", "https://example.invalid/\ninjected"]: + with self.subTest(url=url): + self.assertNotEqual(self.invoke(url=url).returncode, 0) + (self.root / "etc/os-release").write_text('ID=fedora\nVERSION_ID="43"\n') + self.assertNotEqual(self.invoke().returncode, 0) + (self.root / "etc/os-release").write_text('ID=fedora\nVERSION_ID="44"\n') + self.assertNotEqual(self.invoke(env={"TEST_ARCH": "aarch64"}).returncode, 0) + self.assertEqual(self.requests, []) + + def test_os_release_is_data(self): + sentinel = self.work / "must-not-exist" + (self.root / "etc/os-release").write_text(f'ID="$(touch {sentinel})"\nVERSION_ID=44\n') + self.assertNotEqual(self.invoke().returncode, 0) + self.assertFalse(sentinel.exists()) + + def test_unmanaged_and_symlinked_paths_are_preserved(self): + self.repo.parent.mkdir(parents=True) + self.repo.write_text("# other owner\n") + self.assertNotEqual(self.invoke().returncode, 0) + self.assertNotEqual(self.invoke("--remove").returncode, 0) + self.repo.unlink() + outside = self.work / "outside" + outside.mkdir(exist_ok=True) + (self.root / "etc/yum.repos.d").rmdir() + (self.root / "etc/yum.repos.d").symlink_to(outside, target_is_directory=True) + self.assertNotEqual(self.invoke().returncode, 0) + self.assertEqual(list(outside.iterdir()), []) + + def test_remove_is_idempotent_and_preserves_other_repositories(self): + self.assertEqual(self.invoke().returncode, 0) + other = self.repo.with_name("unrelated.repo") + other.write_text("keep") + for _ in range(2): + result = self.invoke("--remove") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.repo.exists()) + self.assertFalse(self.key.exists()) + self.assertEqual(other.read_text(), "keep") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-fedora-repository-vm-proof.mjs b/scripts/test-fedora-repository-vm-proof.mjs new file mode 100755 index 0000000..170d0d8 --- /dev/null +++ b/scripts/test-fedora-repository-vm-proof.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { + parseInstalledHashes, + parsePayloadRelease, + parseReleaseChecksums, + verifyReleaseAssetManifest, + verifyReleaseSignature, + verifyInstalledStage, + verifyLifecycle, + verifyPackageEntry, + verifyRpmSignature, +} from "./verify-fedora-repository-vm-proof.mjs"; + +const directory = await mkdtemp(path.join(tmpdir(), "loopwire-fedora-proof-test-")); +const baseline = "0.1.0-1.fc44"; +const upgrade = "0.1.0+dnffixture1-1.fc44"; +const packageName = `loopwire-${baseline}.x86_64.rpm`; +const packageSha256 = "b".repeat(64); +const sourceSha256 = "c".repeat(64); +const fingerprint = "1234567890ABCDEF1234567890ABCDEF12345678"; +const expectedHashes = Object.fromEntries([ + "/usr/bin/loopwire", "/usr/bin/loopwire-dsp-provider", "/usr/bin/loopwire-jack-ports", + "/usr/bin/loopwire-detect-audio", "/usr/lib/loopwire/loopwire-gui", + "/usr/share/applications/loopwire.desktop", "/usr/share/icons/hicolor/scalable/apps/loopwire.svg", +].map((name) => [name, "a".repeat(64)])); +const signature = `${packageName}:\n Header OpenPGP V4 RSA/SHA512 signature, key fingerprint: ${fingerprint.toLowerCase()}: OK\n Header SHA256 digest: OK\n Payload SHA256 digest: OK\n`; +const transitions = [ + `install\t${baseline}\tinstalled`, `reinstall\t${baseline}\tinstalled`, `upgrade\t${upgrade}\tinstalled`, + `rollback\t${baseline}\tinstalled`, `remove\t${baseline}\tabsent`, +].join("\n"); +const releaseManifest = { + schema: "loopwire.release-assets.v1", + release: { tag: "v0.1.0", version: "0.1.0", gitHead: "e".repeat(40) }, + artifacts: [ + { name: packageName, kind: "native-rpm", target: "fedora-44", architecture: "x86_64", bytes: 123, sha256: sourceSha256 }, + { name: "loopwire-linux-x86_64.tar.gz", kind: "portable-archive", target: "linux-generic", architecture: "x86_64", + bytes: 456, sha256: "d".repeat(64) }, + ], +}; +const releaseExpected = { version: "0.1.0", rpmName: packageName, rpmBytes: 123, rpmSha256: sourceSha256, + tarBytes: 456, tarSha256: "d".repeat(64) }; +let passed = 0; + +async function test(name, action) { + await action(); + passed += 1; + console.log(`PASS ${name}`); +} +async function stageFixture() { + await rm(path.join(directory, "install"), { recursive: true, force: true }); + await mkdir(path.join(directory, "install"), { recursive: true }); + const files = { + "package-metadata.tsv": `loopwire\t${baseline}\tx86_64\n`, + "dnf-origin.txt": `loopwire|${baseline}|x86_64|loopwire\n`, + "dnf-info.txt": `Installed packages\nName : loopwire\nVersion : 0.1.0\nRelease : 1.fc44\nArchitecture : x86_64\nFrom repository : loopwire\n`, + "package-files.txt": `${Object.keys(expectedHashes).join("\n")}\n`, + "installed-files.sha256": `${Object.entries(expectedHashes).map(([name, hash]) => `${hash} ${name}`).join("\n")}\n`, + "signed-package.sha256": `${packageSha256} ${packageName}\n`, + "rpm-signature.txt": signature, + "background-help.txt": "Usage: Loopwire background restore\n", + "dsp-provider-help.txt": "Usage: Loopwire DSP provider\n", + "jack-provider-help.txt": "Usage: Loopwire JACK provider\n", + "detect-audio.json": "{\"backends\":[]}\n", + "gui-ldd.txt": "libgtk-3.so.0 => /lib64/libgtk-3.so.0\nlibwebkit2gtk-4.1.so.0 => /lib64/libwebkit2gtk-4.1.so.0\n", + "gui-launch-status.txt": "0\n", "gui-window-ids.txt": "1234\n", "gui-window-names.txt": "Loopwire\n", + "gui-launch.log": "", "xvfb.log": "", + }; + for (const [name, content] of Object.entries(files)) await writeFile(path.join(directory, "install", name), content); +} +async function verifyStage() { + await verifyInstalledStage(directory, "install", baseline, fingerprint, packageName, packageSha256, expectedHashes); +} +async function change(name, transform) { + const file = path.join(directory, "install", name); + await writeFile(file, transform(await readFile(file, "utf8"))); +} + +try { + await test("complete ordered lifecycle accepted", () => verifyLifecycle(transitions, baseline, upgrade)); + await test("missing reinstall rejected", () => assert.throws(() => verifyLifecycle( + transitions.split("\n").filter((line) => !line.startsWith("reinstall")).join("\n"), baseline, upgrade), /lifecycle transitions/)); + await test("rollback remaining at new version rejected", () => assert.throws(() => verifyLifecycle( + transitions.replace(`rollback\t${baseline}`, `rollback\t${upgrade}`), baseline, upgrade), /lifecycle transitions/)); + await test("fabricated summary pass is insufficient", () => assert.throws(() => verifyLifecycle("pass", baseline, upgrade), /lifecycle transitions/)); + await test("duplicate file hash rejected", () => assert.throws(() => parseInstalledHashes( + `${"a".repeat(64)} /usr/bin/loopwire\n${"b".repeat(64)} /usr/bin/loopwire\n`), /duplicate/)); + await test("parent traversal hash rejected", () => assert.throws(() => parseInstalledHashes( + `${"a".repeat(64)} /usr/../etc/passwd\n`), /invalid/)); + await test("selective signed release checksums accepted", () => assert.deepEqual(parseReleaseChecksums( + `${sourceSha256} ${packageName}\n${"d".repeat(64)} loopwire-linux-x86_64.tar.gz\n${"e".repeat(64)} release-assets.json\n`), + new Map([[packageName, sourceSha256], ["loopwire-linux-x86_64.tar.gz", "d".repeat(64)], + ["release-assets.json", "e".repeat(64)]]))); + await test("duplicate signed release checksum rejected", () => assert.throws(() => parseReleaseChecksums( + `${sourceSha256} ${packageName}\n${sourceSha256} ${packageName}\n`), /duplicate/)); + await test("valid public release signature accepted and tamper rejected", async () => { + const signing = path.join(directory, "release-signing"); + await mkdir(signing); + const privateKey = path.join(signing, "private.pem"); + const publicKey = path.join(signing, "public.pem"); + const checksums = path.join(signing, "SHA256SUMS"); + const signatureFile = path.join(signing, "SHA256SUMS.sig"); + await writeFile(checksums, `${sourceSha256} ${packageName}\n`); + for (const args of [["genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", "-out", privateKey], + ["pkey", "-in", privateKey, "-pubout", "-out", publicKey], + ["dgst", "-sha256", "-sign", privateKey, "-out", signatureFile, checksums]]) { + const result = spawnSync("openssl", args, { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + } + verifyReleaseSignature(checksums, signatureFile, publicKey); + await writeFile(checksums, `${"0".repeat(64)} ${packageName}\n`); + assert.throws(() => verifyReleaseSignature(checksums, signatureFile, publicKey), /openssl verification failed/); + }); + await test("exact public release manifest accepted", () => verifyReleaseAssetManifest( + JSON.stringify(releaseManifest), releaseExpected)); + await test("wrong Fedora manifest target rejected", () => assert.throws(() => verifyReleaseAssetManifest( + JSON.stringify({ ...releaseManifest, artifacts: releaseManifest.artifacts.map((entry, index) => index ? entry : { ...entry, target: "opensuse-tumbleweed" }) }), + releaseExpected), /artifact count/)); + await test("wrong Fedora manifest hash rejected", () => assert.throws(() => verifyReleaseAssetManifest( + JSON.stringify({ ...releaseManifest, artifacts: releaseManifest.artifacts.map((entry, index) => index ? entry : { ...entry, sha256: "0".repeat(64) }) }), + releaseExpected), /Fedora release artifact/)); + await test("wrong public release version rejected", () => assert.throws(() => verifyReleaseAssetManifest( + JSON.stringify({ ...releaseManifest, release: { ...releaseManifest.release, version: "0.2.0" } }), releaseExpected), /public release version/)); + const releaseText = "name=loopwire\nversion=0.1.0\narch=x86_64\nsource_date_epoch=1788115521\n"; + await test("exact portable RELEASE accepted", () => parsePayloadRelease(releaseText, "0.1.0")); + await test("wrong portable RELEASE version rejected", () => assert.throws(() => parsePayloadRelease( + releaseText.replace("version=0.1.0", "version=0.2.0"), "0.1.0"), /RELEASE version/)); + await test("valid RPM signature accepted", () => verifyRpmSignature(signature, fingerprint, packageName)); + await test("RPM NOKEY status rejected", () => assert.throws(() => verifyRpmSignature( + signature.replace(": OK", ": NOKEY"), fingerprint, packageName), /failed/)); + await test("RPM signed by wrong key rejected", () => assert.throws(() => verifyRpmSignature( + signature.replace(fingerprint.toLowerCase(), "0".repeat(40)), fingerprint, packageName), /wrong key/)); + await test("unsigned RPM output rejected", () => assert.throws(() => verifyRpmSignature( + `${packageName}: digests OK\n`, fingerprint, packageName), /lacks/)); + const packageEntry = { + name: "loopwire", version: "0.1.0", release: "1.fc44", architecture: "x86_64", + path: `packages/${packageName}`, sourceReleaseSha256: sourceSha256, + distributedSha256: packageSha256, size: 123, + }; + await test("exact signed package manifest entry accepted", () => verifyPackageEntry( + packageEntry, { version: "0.1.0", name: packageName }, packageSha256, sourceSha256, "fixture")); + await test("public baseline source hash substitution rejected", () => assert.throws(() => verifyPackageEntry( + { ...packageEntry, sourceReleaseSha256: "d".repeat(64) }, { version: "0.1.0", name: packageName }, + packageSha256, sourceSha256, "fixture"), /source release hash/)); + await test("distributed RPM substitution rejected", () => assert.throws(() => verifyPackageEntry( + { ...packageEntry, distributedSha256: "d".repeat(64) }, { version: "0.1.0", name: packageName }, + packageSha256, sourceSha256, "fixture"), /distributed RPM hash/)); + + await stageFixture(); + await test("complete Fedora installed stage accepted", verifyStage); + for (const [name, file, mutation, pattern] of [ + ["changed installed bytes rejected", "installed-files.sha256", (value) => value.replace("a".repeat(64), "d".repeat(64)), /signed repository RPM payload/], + ["wrong installed version rejected", "package-metadata.tsv", (value) => value.replace(baseline, upgrade), /package metadata/], + ["local RPM installation without repository origin rejected", "dnf-origin.txt", (value) => value.replace("|loopwire\n", "|@commandline\n"), /repository origin/], + ["wrong signed RPM digest rejected", "signed-package.sha256", (value) => value.replace(packageSha256, "d".repeat(64)), /signed RPM digest/], + ["failed RPM signature rejected", "rpm-signature.txt", (value) => value.replace(": OK", ": NOT OK"), /signature verification failed/], + ["unresolved GUI dependency rejected", "gui-ldd.txt", (value) => `${value}libmissing.so => not found\n`, /linkage/], + ["unrelated X11 window rejected", "gui-window-names.txt", () => "xterm\n", /application window/], + ["failed GUI process rejected", "gui-launch-status.txt", () => "124\n", /GUI launch/], + ["GUI panic rejected", "gui-launch.log", () => "thread main panicked\n", /fatal GUI log/], + ["empty provider output rejected", "dsp-provider-help.txt", () => "", /empty evidence/], + ["missing installed helper rejected", "package-files.txt", (value) => value.replace("/usr/bin/loopwire-dsp-provider\n", ""), /missing/], + ]) { + await stageFixture(); + await change(file, mutation); + await test(name, () => assert.rejects(verifyStage, pattern)); + } + console.log(`Fedora VM proof verifier tests passed: ${passed}`); +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/scripts/test-fedora-workflow-preflight.py b/scripts/test-fedora-workflow-preflight.py new file mode 100755 index 0000000..539a63e --- /dev/null +++ b/scripts/test-fedora-workflow-preflight.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).with_name("publish-fedora-workflow.sh").resolve() + + +class PreflightTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="loopwire-fedora-preflight-") + self.root = Path(self.temp.name) + self.addCleanup(self.temp.cleanup) + binary = self.root / "bin" + binary.mkdir() + gpg = binary / "gpg" + gpg.write_text('#!/bin/sh\nprintf called > "$FEDORA_TEST_MARKER"\nexit 1\n') + gpg.chmod(0o755) + self.marker = self.root / "used-key" + self.env = { + **os.environ, "PATH": f"{binary}:{os.environ['PATH']}", "FEDORA_TEST_MARKER": str(self.marker), + "FEDORA_REPOSITORY_URL": "https://packages.example.invalid/fedora/44/x86_64", + "FEDORA_REPOSITORY_HOST": "publisher@example.invalid", "FEDORA_REPOSITORY_ROOT": "/srv/loopwire-rpm", + "FEDORA_SIGNING_FINGERPRINT": "A" * 40, "FEDORA_SSH_PRIVATE_KEY": "private-ssh-fixture", + "FEDORA_SSH_KNOWN_HOSTS": "known-hosts-fixture", "FEDORA_SIGNING_KEY": "private-gpg-fixture", + "RUNNER_TEMP": str(self.root), "GITHUB_REPOSITORY": "sandwichfarm/loopwire", + "GITHUB_SERVER_URL": "https://github.com", "GITHUB_RUN_ID": "123", + "OPERATION": "publish", "RELEASE_TAG": "v1.2.3", + } + + def rejected(self, values, message): + result = subprocess.run(["bash", str(SCRIPT)], env={**self.env, **values}, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn(message, result.stderr) + self.assertFalse(self.marker.exists(), "invalid input reached signing-key operations") + self.assertNotIn("private-ssh-fixture", result.stdout + result.stderr) + self.assertNotIn("private-gpg-fixture", result.stdout + result.stderr) + + def test_https_url_rejected_before_keys_or_origin_access(self): + for url in ["http://example.invalid", "https://user:pass@example.invalid", "https://example.invalid/?", + "https://example.invalid/#", "https://example.invalid/\ninjected"]: + with self.subTest(url=url): + self.rejected({"FEDORA_REPOSITORY_URL": url}, "base URL") + + def test_missing_configuration(self): + self.rejected({"FEDORA_SIGNING_KEY": ""}, "missing configuration: FEDORA_SIGNING_KEY") + + def test_tag_rollback_and_fingerprint_validation(self): + self.rejected({"RELEASE_TAG": "v1.2.3; unexpected"}, "stable vX.Y.Z") + self.rejected({"OPERATION": "rollback", "ROLLBACK_REVISION": "HEAD"}, "revision SHA-256") + self.rejected({"FEDORA_SIGNING_FINGERPRINT": "short"}, "fingerprint") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-fedora-workflow.rb b/scripts/test-fedora-workflow.rb new file mode 100755 index 0000000..dd1026a --- /dev/null +++ b/scripts/test-fedora-workflow.rb @@ -0,0 +1,41 @@ +#!/usr/bin/env ruby +require 'yaml' + +root = File.expand_path('..', __dir__) +workflow = YAML.safe_load_file(File.join(root, '.github/workflows/publish-fedora.yml')) +release = YAML.safe_load_file(File.join(root, '.github/workflows/release.yml')) +events = workflow['on'] || workflow[true] +check = ->(condition, message) { raise message unless condition } +check.call(!events.key?('push') && !events.key?('pull_request'), 'Fedora publication must not run on arbitrary source changes') +check.call(events.key?('workflow_call') && events.key?('workflow_dispatch'), 'release and operator entrypoints required') +check.call(events.fetch('schedule').any? { |item| item['cron'] == '53 5 * * 1' }, 'weekly metadata refresh required') +check.call(events.dig('workflow_dispatch', 'inputs', 'operation', 'options') == %w[publish refresh rollback], 'operator operations drifted') +check.call(workflow.dig('permissions', 'contents') == 'read', 'GitHub access must stay read-only') +check.call(workflow.dig('concurrency', 'cancel-in-progress') == false, 'active metadata promotion must not be cancelled') +job = workflow.dig('jobs', 'publish') +check.call(job['environment'] == 'packages-production', 'production secrets must be environment-scoped') +check.call(job['if'].include?("vars.FEDORA_REPOSITORY_ENABLED == 'true'"), 'explicit repository enablement required') +check.call(job['if'].include?('github.event.repository.default_branch'), 'operator runs must use reviewed default-branch code') +check.call(job['if'] == job['if'].strip, 'job condition has literal trailing whitespace') +check.call(job.dig('container', 'image').match?(/^fedora:44@sha256:[a-f0-9]{64}$/), 'workflow must pin its Fedora toolchain') +publisher = job.fetch('steps').find { |step| step['run'] == 'bash scripts/publish-fedora-workflow.sh' } +check.call(publisher, 'workflow must use the reviewed publisher entrypoint') +check.call(publisher.dig('env', 'OPERATION') == "${{ inputs.operation || 'refresh' }}", 'scheduled runs must refresh') +%w[FEDORA_SIGNING_KEY FEDORA_SSH_PRIVATE_KEY FEDORA_SSH_KNOWN_HOSTS FEDORA_SIGNING_PASSPHRASE].each do |name| + check.call(publisher.dig('env', name) == "${{ secrets.#{name} }}", "#{name} must come from secrets") +end +caller = release.dig('jobs', 'publish-fedora') +check.call(caller['needs'] == 'publish-release', 'Fedora publication must wait for existing release gates') +check.call(caller['uses'] == './.github/workflows/publish-fedora.yml', 'release must reuse the reviewed workflow') +check.call(caller.dig('with', 'tag') == '${{ needs.publish-release.outputs.tag }}', 'use only verified release tag output') + +script = File.read(File.join(root, 'scripts/publish-fedora-workflow.sh')) +publish_index = script.index('scripts/publish-rpm-repository.py publish') +%w[verify-release-signature.sh release-asset-manifest.mjs].each do |gate| + check.call(script.index(gate) && script.index(gate) < publish_index, "#{gate} must precede publication") +end +check.call(script.include?('--require-checksum --require-evidence'), 'public release inventory/evidence verification required') +check.call(script.include?('--expected-revision "$expected"'), 'origin publication must use revision CAS') +check.call(script.index('python3 scripts/verify-rpm-public.py') > publish_index, 'activation requires verification of served bytes') +check.call(script.include?('trap cleanup EXIT') && script.include?('unset FEDORA_SSH_PRIVATE_KEY'), 'private files/environment need cleanup') +puts 'Fedora workflow contract passed: pinned toolchain, protected release ordering, secret transport, refresh and public proof.' diff --git a/scripts/test-publish-rpm-repository.py b/scripts/test-publish-rpm-repository.py new file mode 100644 index 0000000..9915166 --- /dev/null +++ b/scripts/test-publish-rpm-repository.py @@ -0,0 +1,794 @@ +#!/usr/bin/env python3 +"""Regression tests for the Fedora RPM repository publisher. + +Run locally with Python's standard library: + python3 scripts/test-publish-rpm-repository.py + +Pass --with-ssh inside the pinned RPM tools container to start a disposable +loopback-only sshd and exercise publish/fetch/recover with generated client and +host keys. No production host, credentials, keyring, or repository is touched. +""" + +import argparse +import base64 +import fcntl +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import socket +import stat +import subprocess +import sys +import tarfile +import tempfile +import time +import unittest +import urllib.error +import urllib.request +from unittest import mock + + +SCRIPT = Path(__file__).with_name("publish-rpm-repository.py") +WITH_SSH = False +FPR = "A" * 40 + + +def load(name, path): + specification = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +publisher = load("rpm_publisher", SCRIPT) + + +def write_manifest(root, manifest): + manifest.pop("revision", None) + manifest["revision"] = hashlib.sha256(publisher.canonical(manifest)).hexdigest() + (root / publisher.MANIFEST).write_text(json.dumps(manifest), encoding="utf-8") + return manifest + + +def fixture(root, version="1.0.0", created=1, package_bytes=None): + root.mkdir() + package = f"packages/loopwire-{version}-1.fc44.x86_64.rpm" + files = { + package: package_bytes or f"rpm package {version}".encode(), + f"keys/{FPR}.asc": b"synthetic public key", + "repodata/repomd.xml": f"repomd {version} {created}".encode(), + "repodata/repomd.xml.asc": f"signature {version} {created}".encode(), + } + for kind in ("primary", "filelists", "other"): + data = f"{kind} {version} {created}".encode() + files[f"repodata/{hashlib.sha256(data).hexdigest()}-{kind}.xml.gz"] = data + entries = [] + for path, data in sorted(files.items()): + target = root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + entries.append({ + "path": path, + "kind": publisher.classify(path), + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + }) + package_data = files[package] + manifest = { + "schema": publisher.SCHEMA, + "schemaVersion": 1, + "createdAt": created, + "validUntil": created + 2592000, + "signingFingerprint": FPR, + "target": publisher.TARGET.copy(), + "packages": [{ + "name": "loopwire", + "version": version, + "release": "1.fc44", + "architecture": "x86_64", + "path": package, + "sourceReleaseSha256": "1" * 64, + "distributedSha256": hashlib.sha256(package_data).hexdigest(), + "size": len(package_data), + }], + "files": entries, + } + return json.loads(json.dumps(write_manifest(root, manifest))) + + +class PublicationTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="loopwire-rpm-publisher-tests-") + self.directory = Path(self.temporary.name) + self.root = self.directory / "origin" + self.first = self.directory / "first" + self.second = self.directory / "second" + self.one = fixture(self.first) + self.two = fixture(self.second, "1.1.0", 2) + + def tearDown(self): + self.temporary.cleanup() + + @property + def channel(self): + return publisher.public_channel(self.root) + + def publish_first(self): + return publisher.publish_at(self.root, self.first, FPR, "empty") + + def assert_current(self, revision): + self.assertEqual(publisher.state(self.root, "current"), {"revision": revision}) + + def test_publish_idempotence_cas_retention_and_fetch(self): + self.assertEqual(self.publish_first()["status"], "published") + self.assertEqual(self.publish_first()["status"], "unchanged") + with self.assertRaisesRegex(publisher.PublicationError, "compare-and-swap"): + publisher.publish_at(self.root, self.second, FPR, "empty") + publisher.publish_at(self.root, self.second, FPR, self.one["revision"]) + self.assert_current(self.two["revision"]) + old_package = self.one["packages"][0]["path"] + self.assertEqual((self.channel / old_package).read_bytes(), + (self.first / old_package).read_bytes()) + self.assertTrue((self.root / "snapshots" / self.one["revision"]).is_dir()) + with publisher.selected_snapshot(self.root, FPR, self.one["revision"]) as (snapshot, manifest): + self.assertEqual(snapshot.name, self.one["revision"]) + self.assertEqual(manifest, self.one) + + def test_restrictive_umask_sets_public_and_private_permissions(self): + previous_umask = os.umask(0o077) + try: + self.publish_first() + + def permissions(path): + return stat.S_IMODE(path.stat().st_mode) + + self.assertEqual(permissions(self.root), 0o755) + public = self.root / "public" + for path in (public, *public.rglob("*")): + self.assertEqual(permissions(path), 0o755 if path.is_dir() else 0o644, + str(path)) + for private in (self.root / "snapshots", self.root / "state"): + for path in (private, *private.rglob("*")): + self.assertEqual(permissions(path), 0o700 if path.is_dir() else 0o600, + str(path)) + self.assertEqual(permissions(self.root / ".publish.lock"), 0o600) + finally: + os.umask(previous_umask) + + def test_existing_operator_root_permissions_are_preserved(self): + self.root.mkdir(mode=0o750) + (self.root / "public").mkdir(mode=0o750) + self.root.chmod(0o750) + (self.root / "public").chmod(0o750) + self.publish_first() + self.assertEqual(stat.S_IMODE(self.root.stat().st_mode), 0o750) + self.assertEqual(stat.S_IMODE((self.root / "public").stat().st_mode), 0o750) + + def test_immutable_collision_fails_before_journal_or_snapshot(self): + self.publish_first() + collision = self.directory / "collision" + changed = fixture(collision, "1.0.0", 3, b"different bytes at immutable URL") + with self.assertRaisesRegex(publisher.PublicationError, "immutable URL collision"): + publisher.publish_at(self.root, collision, FPR, self.one["revision"]) + self.assert_current(self.one["revision"]) + self.assertIsNone(publisher.state(self.root, "pending")) + self.assertFalse((self.root / "snapshots" / changed["revision"]).exists()) + + def test_commit_order_is_signature_then_atomic_repomd(self): + self.publish_first() + events = [] + + def inspect(label): + events.append(label) + public_xml = (self.channel / "repodata/repomd.xml").read_bytes() + public_signature = (self.channel / "repodata/repomd.xml.asc").read_bytes() + if label == "immutable": + self.assertEqual(public_xml, (self.first / "repodata/repomd.xml").read_bytes()) + self.assertEqual(public_signature, + (self.first / "repodata/repomd.xml.asc").read_bytes()) + elif label == "signature": + self.assertEqual(public_xml, (self.first / "repodata/repomd.xml").read_bytes()) + self.assertEqual(public_signature, + (self.second / "repodata/repomd.xml.asc").read_bytes()) + elif label == "committed": + self.assertEqual(public_xml, (self.second / "repodata/repomd.xml").read_bytes()) + self.assertEqual(public_signature, + (self.second / "repodata/repomd.xml.asc").read_bytes()) + + with mock.patch.object(publisher, "_checkpoint", side_effect=inspect): + publisher.publish_at(self.root, self.second, FPR, self.one["revision"]) + self.assertLess(events.index("immutable"), events.index("signature")) + self.assertLess(events.index("signature"), events.index("committed")) + + def test_every_promotion_checkpoint_is_recoverable(self): + for index, checkpoint in enumerate( + ("journal", "immutable", "signature", "committed", "manifest", "current")): + with self.subTest(checkpoint=checkpoint): + root = self.directory / f"checkpoint-{index}" + + def interrupt(label): + if label == checkpoint: + raise InterruptedError("simulated interruption") + + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at(root, self.first, FPR, "empty") + self.assertEqual(publisher.state(root, "pending")["revision"], + self.one["revision"]) + publisher.recover_at(root, FPR, self.one["revision"]) + self.assertEqual(publisher.state(root, "current")["revision"], + self.one["revision"]) + self.assertIsNone(publisher.state(root, "pending")) + + def test_killed_process_leaves_durable_journal_and_blocks_competitors(self): + self.publish_first() + code = ( + "import importlib.util,os,sys; from pathlib import Path; " + "s=importlib.util.spec_from_file_location('p',sys.argv[1]); " + "p=importlib.util.module_from_spec(s); s.loader.exec_module(p); " + "p._checkpoint=lambda label: os._exit(97) if label=='signature' else None; " + "p.publish_at(Path(sys.argv[2]),Path(sys.argv[3]),sys.argv[4],sys.argv[5])" + ) + process = subprocess.run([sys.executable, "-c", code, str(SCRIPT), str(self.root), + str(self.second), FPR, self.one["revision"]], check=False) + self.assertEqual(process.returncode, 97) + self.assert_current(self.one["revision"]) + self.assertEqual(publisher.state(self.root, "pending")["revision"], self.two["revision"]) + with self.assertRaisesRegex(publisher.PublicationError, "recover"): + with publisher.selected_snapshot(self.root, FPR): + pass + with self.assertRaisesRegex(publisher.PublicationError, "recover"): + publisher.publish_at(self.root, self.first, FPR, self.one["revision"]) + publisher.recover_at(self.root, FPR, self.two["revision"]) + self.assert_current(self.two["revision"]) + + def test_exclusive_flock_blocks_publish_fetch_and_recovery(self): + self.publish_first() + descriptor = os.open(self.root / ".publish.lock", os.O_RDONLY | os.O_NOFOLLOW) + fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + with self.assertRaisesRegex(publisher.PublicationError, "locked"): + publisher.publish_at(self.root, self.second, FPR, self.one["revision"]) + with self.assertRaisesRegex(publisher.PublicationError, "locked"): + with publisher.selected_snapshot(self.root, FPR): + pass + finally: + os.close(descriptor) + + def test_separate_process_lock_blocks_concurrent_cas_writer(self): + self.publish_first() + code = ( + "import fcntl,os,sys; " + "fd=os.open(sys.argv[1],os.O_RDONLY|os.O_NOFOLLOW); " + "fcntl.flock(fd,fcntl.LOCK_EX); print('locked',flush=True); " + "sys.stdin.readline(); os.close(fd)" + ) + holder = subprocess.Popen( + [sys.executable, "-c", code, str(self.root / ".publish.lock")], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, + ) + try: + self.assertEqual(holder.stdout.readline().strip(), "locked") + with self.assertRaisesRegex(publisher.PublicationError, "locked"): + publisher.publish_at(self.root, self.second, FPR, self.one["revision"]) + self.assert_current(self.one["revision"]) + finally: + holder.stdin.write("release\n") + holder.stdin.flush() + holder.wait(timeout=10) + holder.stdin.close() + holder.stdout.close() + + def test_empty_fetch_has_no_filesystem_side_effect(self): + with self.assertRaises(publisher.EmptyRepository): + with publisher.selected_snapshot(self.root, FPR): + pass + self.assertFalse(self.root.exists()) + + def test_malicious_paths_kind_target_and_revision_fail_before_write(self): + for index, bad in enumerate(("../../escape", "/etc/passwd", "repodata/../escape", + "state/current.json", "packages//x.rpm")): + with self.subTest(path=bad): + candidate = self.directory / f"bad-path-{index}" + manifest = fixture(candidate, f"2.0.{index}") + manifest["files"][0]["path"] = bad + write_manifest(candidate, manifest) + with self.assertRaises(publisher.PublicationError): + publisher.publish_at(self.root, candidate, FPR, "empty") + self.assertFalse(self.root.exists()) + self.one["target"]["release"] = "45" + write_manifest(self.first, self.one) + with self.assertRaisesRegex(publisher.PublicationError, "Fedora 44"): + self.publish_first() + + def test_candidate_symlinks_hardlinks_and_unlisted_files_are_rejected(self): + target = self.first / "unlisted" + target.symlink_to(self.second / publisher.MANIFEST) + with self.assertRaisesRegex(publisher.PublicationError, "symlink"): + self.publish_first() + target.unlink() + os.link(self.first / publisher.MANIFEST, target) + with self.assertRaisesRegex(publisher.PublicationError, "hardlink"): + self.publish_first() + target.unlink() + target.write_text("extra", encoding="utf-8") + with self.assertRaisesRegex(publisher.PublicationError, "unlisted"): + self.publish_first() + self.assertFalse(self.root.exists()) + + def test_origin_and_public_symlinks_and_drift_are_rejected(self): + elsewhere = self.directory / "elsewhere" + elsewhere.mkdir() + self.root.symlink_to(elsewhere, target_is_directory=True) + with self.assertRaisesRegex(publisher.PublicationError, "symlink"): + self.publish_first() + self.root.unlink() + (publisher.public_channel(self.root)).mkdir(parents=True) + (publisher.public_channel(self.root) / "foreign").write_text("unmanaged") + with self.assertRaisesRegex(publisher.PublicationError, "unmanaged"): + self.publish_first() + shutil.rmtree(self.root) + self.publish_first() + target = self.channel / self.one["packages"][0]["path"] + target.unlink() + target.symlink_to(self.first / self.one["packages"][0]["path"]) + with self.assertRaisesRegex(publisher.PublicationError, "symlink"): + self.publish_first() + target.unlink() + target.write_bytes(b"drift") + with self.assertRaisesRegex(publisher.PublicationError, "drifted"): + self.publish_first() + + def test_archive_rejects_traversal_links_specials_and_duplicates(self): + for kind in ("traversal", "symlink", "hardlink", "duplicate"): + with self.subTest(kind=kind): + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w") as output: + name = "../escape" if kind == "traversal" else publisher.MANIFEST + member = tarfile.TarInfo(name) + member.size = 2 + if kind in ("symlink", "hardlink"): + member.type = tarfile.SYMTYPE if kind == "symlink" else tarfile.LNKTYPE + member.linkname = "/etc/passwd" + output.addfile(member, io.BytesIO(b"{}")) + if kind == "duplicate": + output.addfile(member, io.BytesIO(b"{}")) + archive.seek(0) + destination = self.directory / f"archive-{kind}" + destination.mkdir() + with self.assertRaises(publisher.PublicationError): + publisher.read_archive(archive, destination) + + def test_remote_arguments_are_data_and_pinned_credentials_are_required(self): + known = self.directory / "known_hosts" + identity = self.directory / "identity" + known.write_text("host ssh-ed25519 AAAA", encoding="utf-8") + identity.write_text("identity", encoding="utf-8") + args = argparse.Namespace(ssh="publisher@example.invalid", ssh_port=2222, + known_hosts=known, identity_file=identity) + request = {"root": "/tmp/path with 'quotes';$(touch /tmp/unsafe)", "action": "fetch"} + command = publisher.ssh_command(args, request) + self.assertEqual(command[1:3], ["-F", "/dev/null"]) + self.assertIn("StrictHostKeyChecking=yes", command) + self.assertIn("ForwardAgent=no", command) + self.assertIn("GlobalKnownHostsFile=/dev/null", command) + remote = __import__("shlex").split(command[-1]) + self.assertEqual(remote[:2], ["python3", "-c"]) + self.assertEqual(json.loads(base64.urlsafe_b64decode(remote[-1])), request) + args.identity_file = None + with self.assertRaisesRegex(publisher.PublicationError, "identity"): + publisher.ssh_command(args, request) + args.identity_file = identity + args.ssh = "publisher@host;touch" + with self.assertRaises(publisher.PublicationError): + publisher.ssh_command(args, request) + + def test_expired_recovery_requires_explicit_historical_verification(self): + expired = self.directory / "expired" + manifest = fixture(expired, "2.0.0", int(time.time()) - 2592001) + + def interrupt(label): + if label == "journal": + raise InterruptedError("expired pending journal") + + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at(self.root, expired, FPR, "empty") + key = self.directory / "key.asc" + key.write_text("synthetic", encoding="utf-8") + base = argparse.Namespace(root=str(self.root), public_key=key, fingerprint=FPR, + ssh=None, ssh_port=None, identity_file=None, + known_hosts=None, action="recover", dry_run=True, + allow_expired=False) + + def verify(_root, _key, _fingerprint, historical=False): + if not historical: + raise publisher.PublicationError("expired repository") + return manifest + + with mock.patch.object(publisher, "verify_signed", side_effect=verify): + with self.assertRaisesRegex(publisher.PublicationError, "expired"): + publisher.run(base) + base.allow_expired = True + checked = publisher.run(base) + self.assertTrue(checked["requiresRefresh"]) + base.dry_run = False + completed = publisher.run(base) + self.assertTrue(completed["requiresRefresh"]) + self.assertIn("Immediately", completed["nextAction"]) + + +class SignedDnfCommitTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not WITH_SSH: + raise unittest.SkipTest("runs with --with-ssh in the pinned RPM tools container") + required = ("createrepo_c", "dnf", "gpg", "openssl", "rpmbuild", "rpmkeys", "rpmsign") + missing = [tool for tool in required if shutil.which(tool) is None] + if missing: + raise unittest.SkipTest("missing RPM tools: " + ", ".join(missing)) + cls.temporary = tempfile.TemporaryDirectory(prefix="loopwire-rpm-dnf-tests-") + cls.directory = Path(cls.temporary.name) + cls.gnupg = cls.directory / "gnupg" + cls.gnupg.mkdir(mode=0o700) + cls.date = int(time.time()) - 120 + cls.shell( + "gpg", "--homedir", cls.gnupg, "--batch", "--pinentry-mode", "loopback", + "--passphrase", "", "--faked-system-time", f"{cls.date - 60}!", + "--quick-generate-key", "Loopwire Fedora publisher fixture", "rsa2048", "sign", "1y", + ) + listing = cls.shell("gpg", "--homedir", cls.gnupg, "--with-colons", "--list-keys").stdout + cls.fingerprint = next(line.split(":")[9] for line in listing.splitlines() + if line.startswith("fpr:")) + cls.public_key = cls.directory / "repository-key.asc" + cls.public_key.write_text(cls.shell( + "gpg", "--homedir", cls.gnupg, "--armor", "--export", cls.fingerprint, + ).stdout, encoding="utf-8") + cls.release_private = cls.directory / "release-private.pem" + cls.release_public = cls.directory / "release-public.pem" + cls.shell("openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", + "-out", cls.release_private) + cls.shell("openssl", "pkey", "-in", cls.release_private, "-pubout", + "-out", cls.release_public) + cls.first = cls.build_candidate("1.0.0", cls.date) + cls.second = cls.build_candidate("1.1.0", cls.date + 1, cls.first) + cls.one = json.loads((cls.first / publisher.MANIFEST).read_text(encoding="utf-8")) + cls.two = json.loads((cls.second / publisher.MANIFEST).read_text(encoding="utf-8")) + + @classmethod + def tearDownClass(cls): + if hasattr(cls, "gnupg"): + subprocess.run(["gpgconf", "--homedir", str(cls.gnupg), "--kill", "gpg-agent"], + check=False, capture_output=True) + if hasattr(cls, "temporary"): + cls.temporary.cleanup() + + @classmethod + def shell(cls, *command, cwd=None, ok=True): + result = subprocess.run(list(map(str, command)), cwd=cwd, capture_output=True, + text=True, check=False) + if ok and result.returncode != 0: + raise AssertionError("command failed: " + " ".join(map(str, command)) + + "\n" + result.stdout + result.stderr) + return result + + @classmethod + def build_rpm(cls, version, release): + top = cls.directory / f"rpmbuild-{version}" + for child in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"): + (top / child).mkdir(parents=True) + spec = top / "SPECS/loopwire.spec" + spec.write_text( + "Name: loopwire\n" + f"Version: {version}\n" + "Release: 1.fc44\nSummary: DNF publisher fixture\n" + "License: MIT\nBuildArch: x86_64\n\n" + "%description\nDNF publisher fixture.\n\n" + "%prep\n\n%build\n\n" + "%install\nmkdir -p %{buildroot}/usr/bin\n" + f"printf '#!/bin/sh\\necho {version}\\n' > %{{buildroot}}/usr/bin/loopwire\n" + "chmod 0755 %{buildroot}/usr/bin/loopwire\n\n" + "%files\n/usr/bin/loopwire\n", + encoding="utf-8", + ) + cls.shell("rpmbuild", "-bb", "--define", f"_topdir {top}", spec) + built = top / f"RPMS/x86_64/loopwire-{version}-1.fc44.x86_64.rpm" + release.mkdir() + target = release / built.name + shutil.copyfile(built, target) + checksums = release / "SHA256SUMS" + checksums.write_text(f"{publisher.digest(target)} {target.name}\n", encoding="utf-8") + cls.shell("openssl", "dgst", "-sha256", "-sign", cls.release_private, + "-out", release / "SHA256SUMS.sig", checksums) + + @classmethod + def build_candidate(cls, version, date, previous=None): + release = cls.directory / f"release-{version}" + cls.build_rpm(version, release) + candidate = cls.directory / f"candidate-{version}" + command = [ + sys.executable, SCRIPT.with_name("rpm-repository.py"), "build", + "--release-dir", release, "--version", version, "--output", candidate, + "--signing-key", cls.fingerprint, "--gnupg-home", cls.gnupg, + "--date", str(date), "--valid-for-days", "30", + "--release-public-key", cls.release_public, + ] + if previous: + command += ["--previous", previous] + cls.shell(*command) + return candidate + + def setUp(self): + self.origin = self.directory / self.id().split(".")[-1] + + def publisher_cli(self, action, *extra, ok=True): + root = self.origin / "origin" + result = self.shell( + sys.executable, SCRIPT, action, "--root", root, + "--public-key", self.public_key, "--fingerprint", self.fingerprint, + *extra, ok=False, + ) + if ok: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result + + def dnf_query(self, label): + repos = self.origin / f"repos-{label}" + cache = self.origin / f"cache-{label}" + persist = self.origin / f"persist-{label}" + repos.mkdir(parents=True) + channel = publisher.public_channel(self.origin / "origin") + (repos / "loopwire.repo").write_text( + "[loopwire]\nname=Loopwire test\nenabled=1\n" + f"baseurl=file://{channel}/\n" + "gpgcheck=1\nrepo_gpgcheck=1\n" + f"gpgkey=file://{channel}/keys/{self.fingerprint}.asc\n" + "metadata_expire=0\n", + encoding="utf-8", + ) + return self.shell( + "dnf", "-y", "--setopt", f"reposdir={repos}", + "--setopt", f"cachedir={cache}", "--setopt", f"persistdir={persist}", + "--disablerepo=*", "--enablerepo=loopwire", "repoquery", "loopwire", + ok=False, + ) + + def test_real_dnf_rejects_interrupted_signature_xml_pair_and_accepts_recovery(self): + root = self.origin / "origin" + publisher.publish_at(root, self.first, self.fingerprint, "empty") + initial = self.dnf_query("initial") + self.assertEqual(initial.returncode, 0, initial.stdout + initial.stderr) + self.assertIn("loopwire-0:", initial.stdout) + + def interrupt(label): + if label == "signature": + raise InterruptedError("leave new signature with old repomd.xml") + + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at(root, self.second, self.fingerprint, self.one["revision"]) + mixed = self.dnf_query("mixed") + # DNF5 currently exits zero after excluding a repository whose metadata + # signature failed, so package absence plus its explicit verification + # diagnostic is the acceptance boundary. + self.assertIn("Bad PGP signature", mixed.stdout + mixed.stderr) + self.assertNotIn("loopwire-0:", mixed.stdout) + publisher.recover_at(root, self.fingerprint, self.two["revision"]) + recovered = self.dnf_query("recovered") + self.assertEqual(recovered.returncode, 0, recovered.stdout + recovered.stderr) + self.assertIn("loopwire-0:", recovered.stdout) + + def test_signed_cli_publish_fetch_idempotence_and_retained_revision(self): + dry = self.publisher_cli( + "publish", "--repository", self.first, "--expected-revision", "empty", + "--dry-run", + ) + self.assertEqual(json.loads(dry.stdout)["status"], "validated") + self.assertFalse((self.origin / "origin").exists()) + first = self.publisher_cli( + "publish", "--repository", self.first, "--expected-revision", "empty", + ) + self.assertEqual(json.loads(first.stdout)["revision"], self.one["revision"]) + unchanged = self.publisher_cli( + "publish", "--repository", self.first, "--expected-revision", "empty", + ) + self.assertEqual(json.loads(unchanged.stdout)["status"], "unchanged") + self.publisher_cli( + "publish", "--repository", self.second, + "--expected-revision", self.one["revision"], + ) + current = self.origin / "current" + fetched = self.publisher_cli("fetch", "--output", current) + self.assertEqual(json.loads(fetched.stdout)["revision"], self.two["revision"]) + retained = self.origin / "retained" + fetched = self.publisher_cli( + "fetch", "--output", retained, "--revision", self.one["revision"], + ) + self.assertEqual(json.loads(fetched.stdout)["revision"], self.one["revision"]) + self.assertEqual((retained / publisher.MANIFEST).read_bytes(), + (self.first / publisher.MANIFEST).read_bytes()) + + +class SshPublicationTests(unittest.TestCase): + def test_actual_ssh_publish_fetch_and_recover_without_remote_gpg(self): + if not WITH_SSH: + self.skipTest("use --with-ssh inside the pinned disposable RPM tools container") + self.assertTrue(Path("/.dockerenv").exists() and os.geteuid() == 0, + "--with-ssh is restricted to root in a disposable container") + sshd = shutil.which("sshd") + self.assertIsNotNone(sshd, "openssh-server is required") + # Fedora's container root account is locked by default. Unlock it only + # inside this disposable container; sshd still permits public-key auth + # exclusively and listens on a random loopback port. + unlocked = subprocess.run(["passwd", "-d", "root"], capture_output=True, + text=True, check=False) + self.assertEqual(unlocked.returncode, 0, unlocked.stderr) + with tempfile.TemporaryDirectory(prefix="loopwire-rpm-ssh-tests-") as temporary: + directory = Path(temporary) + first = directory / "first" + second = directory / "second" + one = fixture(first) + two = fixture(second, "1.1.0", 2) + origin = directory / "origin" + identity = directory / "identity" + host_key = directory / "host-key" + for key in (identity, host_key): + subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", + "-f", str(key)], check=True) + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + known = directory / "known_hosts" + known.write_text(f"[127.0.0.1]:{port} " + host_key.with_suffix(".pub").read_text()) + remote_bin = directory / "remote-bin" + remote_bin.mkdir() + (remote_bin / "python3").symlink_to(sys.executable) + configuration = directory / "sshd.conf" + configuration.write_text( + f"Port {port}\nListenAddress 127.0.0.1\nHostKey {host_key}\n" + f"PidFile {directory / 'sshd.pid'}\nAuthorizedKeysFile {identity}.pub\n" + "PermitRootLogin prohibit-password\nPasswordAuthentication no\n" + "KbdInteractiveAuthentication no\nUsePAM no\nStrictModes no\nAllowUsers root\n" + f"LogLevel ERROR\nSetEnv PATH={remote_bin}\n") + Path("/run/sshd").mkdir(exist_ok=True) + with (directory / "sshd.log").open("wb") as log: + server = subprocess.Popen([sshd, "-D", "-e", "-f", str(configuration)], + stdout=log, stderr=log) + try: + for _ in range(100): + self.assertIsNone(server.poll(), "temporary sshd exited") + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + break + except OSError: + time.sleep(0.05) + connection = argparse.Namespace( + ssh="root@127.0.0.1", ssh_port=port, + identity_file=identity, known_hosts=known, + ) + probe = publisher.ssh_command(connection, {}) + probe[-1] = "python3 -c 'import shutil; assert shutil.which(\"gpg\") is None'" + result = subprocess.run(probe, capture_output=True, text=True, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + result = publisher.remote_call(connection, { + "action": "publish", "root": str(origin), "fingerprint": FPR, + "expected": "empty", + }, repository=first) + self.assertEqual(result["revision"], one["revision"]) + fetched = directory / "fetched" + fetched.mkdir() + publisher.remote_call(connection, { + "action": "fetch", "root": str(origin), "fingerprint": FPR, + "revision": None, + }, output=fetched) + self.assertEqual((fetched / publisher.MANIFEST).read_bytes(), + (first / publisher.MANIFEST).read_bytes()) + def interrupt(label): + if label == "signature": + raise InterruptedError("simulate remote process loss") + + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at(origin, second, FPR, one["revision"]) + pending = directory / "pending" + pending.mkdir() + publisher.remote_call(connection, { + "action": "fetch-pending", "root": str(origin), + "fingerprint": FPR, "revision": None, + }, output=pending) + self.assertEqual((pending / publisher.MANIFEST).read_bytes(), + (second / publisher.MANIFEST).read_bytes()) + result = publisher.remote_call(connection, { + "action": "recover", "root": str(origin), "fingerprint": FPR, + "revision": two["revision"], + }) + self.assertEqual(result["revision"], two["revision"]) + self.assertIsNone(publisher.state(origin, "pending")) + known.write_text("", encoding="utf-8") + with self.assertRaisesRegex(publisher.PublicationError, "SSH"): + publisher.remote_call(connection, { + "action": "fetch", "root": str(origin), "fingerprint": FPR, + "revision": None, + }, output=directory / "untrusted") + finally: + server.terminate() + server.wait(timeout=10) + + +class NginxPublicTests(unittest.TestCase): + def test_syntax_and_live_cache_headers(self): + if not WITH_SSH: + self.skipTest("runs with --with-ssh in the pinned RPM tools container") + nginx = shutil.which("nginx") + self.assertIsNotNone(nginx, "nginx is required") + snippet_path = (SCRIPT.parent.parent / "packaging/repositories/nginx-rpm.conf") + with tempfile.TemporaryDirectory(prefix="loopwire-rpm-nginx-tests-") as temporary: + directory = Path(temporary) + origin = directory / "origin" + candidate = directory / "candidate" + manifest = fixture(candidate) + publisher.publish_at(origin, candidate, FPR, "empty") + snippet = directory / "nginx-rpm.conf" + snippet.write_text( + snippet_path.read_text(encoding="utf-8").replace( + "/srv/loopwire-rpm", str(origin)), encoding="utf-8") + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + config = directory / "nginx.conf" + config.write_text( + "user root;\nworker_processes 1;\nerror_log stderr notice;\n" + f"pid {directory / 'nginx.pid'};\nevents {{ worker_connections 64; }}\n" + "http { access_log off; server { " + f"listen 127.0.0.1:{port}; include {snippet};" + " } }\n", encoding="utf-8") + checked = subprocess.run([nginx, "-t", "-c", str(config)], + capture_output=True, text=True, check=False) + self.assertEqual(checked.returncode, 0, checked.stderr) + server = subprocess.Popen([nginx, "-c", str(config), "-g", "daemon off;"], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, + text=True) + try: + base = f"http://127.0.0.1:{port}/fedora/44/x86_64/" + for _ in range(100): + try: + urllib.request.urlopen(base + "repodata/repomd.xml", timeout=0.1).close() + break + except (OSError, urllib.error.URLError): + if server.poll() is not None: + self.fail(server.stderr.read()) + time.sleep(0.02) + + def headers(path): + with urllib.request.urlopen(base + path, timeout=2) as response: + self.assertEqual(response.status, 200) + return response.headers + + self.assertIn("no-store", headers("repodata/repomd.xml")["Cache-Control"]) + self.assertIn("no-store", headers("repodata/repomd.xml.asc")["Cache-Control"]) + package = manifest["packages"][0]["path"] + self.assertIn("immutable", headers(package)["Cache-Control"]) + hashed = next(entry["path"] for entry in manifest["files"] + if entry["path"].endswith("primary.xml.gz")) + self.assertIn("immutable", headers(hashed)["Cache-Control"]) + with self.assertRaises(urllib.error.HTTPError) as missing: + urllib.request.urlopen(base + + "packages/loopwire-9.9.9-1.fc44.x86_64.rpm", timeout=2) + self.assertEqual(missing.exception.code, 404) + self.assertIn("no-store", missing.exception.headers["Cache-Control"]) + missing.exception.close() + finally: + server.terminate() + server.wait(timeout=10) + server.stderr.close() + + +if __name__ == "__main__": + if "--with-ssh" in sys.argv: + WITH_SSH = True + sys.argv.remove("--with-ssh") + unittest.main(verbosity=2) diff --git a/scripts/test-rpm-public.py b/scripts/test-rpm-public.py new file mode 100755 index 0000000..1009ab5 --- /dev/null +++ b/scripts/test-rpm-public.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +import contextlib +import functools +import hashlib +import http.server +import importlib.util +import io +import json +from pathlib import Path +import ssl +import subprocess +import sys +import tempfile +import threading +import unittest +from unittest.mock import patch + + +SCRIPT = Path(__file__).with_name("verify-rpm-public.py") +spec = importlib.util.spec_from_file_location("rpm_public", SCRIPT) +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) + + +class PublicTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.temporary = tempfile.TemporaryDirectory(prefix="loopwire-rpm-public-") + cls.root = Path(cls.temporary.name) + cls.web = cls.root / "web" + cls.web.mkdir() + cls.cert, key = cls.root / "cert.pem", cls.root / "key.pem" + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", + "-subj", "/CN=127.0.0.1", "-addext", "subjectAltName=IP:127.0.0.1", + "-keyout", str(key), "-out", str(cls.cert)], check=True, capture_output=True) + + class Handler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *_args): + pass + + cls.server = http.server.ThreadingHTTPServer( + ("127.0.0.1", 0), functools.partial(Handler, directory=str(cls.web))) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(cls.cert, key) + cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True) + threading.Thread(target=cls.server.serve_forever, daemon=True).start() + cls.url = f"https://127.0.0.1:{cls.server.server_port}" + + @classmethod + def tearDownClass(cls): + cls.server.shutdown() + cls.server.server_close() + cls.temporary.cleanup() + + def setUp(self): + payload = b"signed rpm bytes" + (self.web / "packages").mkdir(exist_ok=True) + (self.web / "packages/loopwire.rpm").write_bytes(payload) + manifest = json.dumps({"target": {"distribution": "fedora", "release": "44", "architecture": "x86_64"}, + "revision": "a" * 64, "files": [{ + "path": "packages/loopwire.rpm", "size": len(payload), "sha256": hashlib.sha256(payload).hexdigest()}]}) + (self.root / "repository-manifest.json").write_text(manifest) + (self.web / "repository-manifest.json").write_text(manifest) + self.output = self.root / "channel.json" + self.output.unlink(missing_ok=True) + + def invoke(self, *extra, verifier_error=None): + args = [str(SCRIPT), "--repository", str(self.root), "--public-key", str(self.root / "key.asc"), + "--fingerprint", "A" * 40, "--base-url", self.url] + if "--output" not in extra: + args += ["--ca-file", str(self.cert)] + args += extra + trust = ssl.create_default_context(cafile=str(self.cert)) + with patch.object(sys, "argv", args), patch.object(module.subprocess, "run") as verifier, \ + patch.object(module.ssl, "create_default_context", return_value=trust), \ + contextlib.redirect_stdout(io.StringIO()) as output: + if verifier_error: + verifier.side_effect = verifier_error + module.main() + verifier.assert_called_once() + self.assertTrue(verifier.call_args.kwargs["check"]) + return json.loads(output.getvalue()) + + def test_exact_public_bytes_produce_fedora_record(self): + result = self.invoke("--output", str(self.output), "--proof-url", + "https://github.com/sandwichfarm/loopwire/actions/runs/123") + self.assertEqual(result["files"], 2) + record = json.loads(self.output.read_text()) + self.assertEqual(record["target"], "fedora-44") + self.assertEqual(record["baseUrl"], self.url) + + def test_package_or_manifest_tamper_fails(self): + for path in [self.web / "packages/loopwire.rpm", self.web / "repository-manifest.json"]: + with self.subTest(path=path.name): + before = path.read_bytes() + path.write_bytes(b"tampered") + with self.assertRaises(ValueError): + self.invoke() + path.write_bytes(before) + + def test_local_signature_failure_prevents_network(self): + with self.assertRaises(subprocess.CalledProcessError): + self.invoke(verifier_error=subprocess.CalledProcessError(1, "verify")) + + def test_custom_ca_cannot_activate(self): + with self.assertRaisesRegex(ValueError, "custom-CA fixture"): + self.invoke("--output", str(self.output), "--ca-file", str(self.cert), "--proof-url", + "https://github.com/sandwichfarm/loopwire/actions/runs/123") + + def test_invalid_url_fingerprint_and_proof_fail(self): + for args in [("--base-url", "http://example.invalid"), ("--fingerprint", "short"), + ("--output", str(self.output), "--proof-url", "https://example.invalid/run/1")]: + with self.subTest(args=args), self.assertRaises(ValueError): + self.invoke(*args) + + def test_wrong_target_fails(self): + manifest = json.loads((self.root / "repository-manifest.json").read_text()) + manifest["target"] = {"distribution": "opensuse", "release": "tumbleweed", "architecture": "x86_64"} + (self.root / "repository-manifest.json").write_text(json.dumps(manifest)) + with self.assertRaisesRegex(ValueError, "Fedora"): + self.invoke() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-rpm-repository.py b/scripts/test-rpm-repository.py new file mode 100644 index 0000000..750f9fb --- /dev/null +++ b/scripts/test-rpm-repository.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Credential-free Fedora repository tests with real RPM, GPG, createrepo, and DNF. + +Run in the pinned tools image built from packaging/repositories/Dockerfile.rpm-tools. +All keys, packages, repositories, web servers, DNF state, and RPM databases are +temporary. No host repository configuration or production credentials are used. +""" + +import functools +import hashlib +import http.server +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import unittest + + +SCRIPT = Path(__file__).with_name("rpm-repository.py") + + +def run(*args, ok=True, **kwargs): + result = subprocess.run([str(argument) for argument in args], capture_output=True, text=True, **kwargs) + if ok and result.returncode: + raise AssertionError(f"{args!r}\n{result.stdout}\n{result.stderr}") + return result + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *_args): + pass + + +class RepositoryTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + for tool in ( + "createrepo_c", "dnf", "gpg", "gpgv", "openssl", "rpm", "rpmbuild", "rpmkeys", "rpmsign", + ): + if not shutil.which(tool): + raise RuntimeError(f"{tool} required; run tests in Dockerfile.rpm-tools") + cls.temporary = tempfile.TemporaryDirectory(prefix="loopwire-rpm-tests-") + cls.root = Path(cls.temporary.name) + cls.root.chmod(0o755) + cls.gnupg = cls.root / "gnupg" + cls.gnupg.mkdir(mode=0o700) + cls.fingerprints = [] + for identity in ("Loopwire RPM Test", "Wrong RPM Test"): + run( + "gpg", "--homedir", cls.gnupg, "--batch", "--pinentry-mode", "loopback", + "--passphrase", "", "--quick-generate-key", identity, "rsa2048", "sign", "0", + ) + listing = run("gpg", "--homedir", cls.gnupg, "--with-colons", "--list-keys", identity).stdout + cls.fingerprints.append(next( + line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:") + )) + cls.fingerprint, cls.wrong_fingerprint = cls.fingerprints + cls.key = cls.root / "repository.asc" + cls.key.write_text(run( + "gpg", "--homedir", cls.gnupg, "--armor", "--export", cls.fingerprint, + ).stdout) + cls.wrong_key = cls.root / "wrong.asc" + cls.wrong_key.write_text(run( + "gpg", "--homedir", cls.gnupg, "--armor", "--export", cls.wrong_fingerprint, + ).stdout) + cls.release_private = cls.root / "release-private.pem" + cls.release_public = cls.root / "release-public.pem" + run( + "openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", + "-out", cls.release_private, + ) + run("openssl", "pkey", "-in", cls.release_private, "-pubout", "-out", cls.release_public) + cls.date = int(time.time()) + cls.release1 = cls.make_release("1.0.0") + cls.release2 = cls.make_release("1.1.0") + cls.base = cls.root / "base" + cls.build(cls.release1, "1.0.0", cls.base) + + @classmethod + def tearDownClass(cls): + run("gpgconf", "--homedir", cls.gnupg, "--kill", "gpg-agent", ok=False) + cls.temporary.cleanup() + + @classmethod + def make_rpm( + cls, version, *, name="loopwire", release="1.fc44", architecture="x86_64", suffix="", + ): + fixture = cls.root / f"rpm-{version}-{name}-{release}-{architecture}{suffix}" + top = fixture / "rpmbuild" + for directory in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"): + (top / directory).mkdir(parents=True) + spec = top / "SPECS/fixture.spec" + spec.write_text( + f"Name: {name}\nVersion: {version}\nRelease: {release}\n" + "Summary: Loopwire repository test fixture\nLicense: MIT\n" + f"BuildArch: {architecture}\n\n" + "%description\nRepository fixture.\n\n%prep\n\n%build\n\n" + "%install\nmkdir -p %{buildroot}/usr/share/loopwire\n" + f"printf '%s\\n' '{version}{suffix}' > %{{buildroot}}/usr/share/loopwire/fixture\n\n" + "%files\n/usr/share/loopwire/fixture\n", + ) + run( + "rpmbuild", "--define", f"_topdir {top}", "--define", "_buildhost fixture.invalid", + "--define", f"_source_date_epoch {cls.date}", "--define", "use_source_date_epoch_as_buildtime 1", + "-bb", spec, + ) + packages = list((top / "RPMS").glob("**/*.rpm")) + if len(packages) != 1: + raise AssertionError(f"expected one RPM fixture, got {packages}") + return packages[0] + + @classmethod + def make_release( + cls, version, *, name="loopwire", release="1.fc44", architecture="x86_64", suffix="", + ): + directory = cls.root / f"release-{version}-{name}-{release}-{architecture}{suffix}" + directory.mkdir() + built = cls.make_rpm( + version, name=name, release=release, architecture=architecture, suffix=suffix, + ) + filename = f"loopwire-{version}-1.fc44.x86_64.rpm" + shutil.copyfile(built, directory / filename) + cls.sign_release(directory) + return directory + + @classmethod + def sign_release(cls, release): + packages = sorted(release.glob("*.rpm")) + (release / "SHA256SUMS").write_text("".join( + f"{digest(package)} {package.name}\n" for package in packages + )) + run( + "openssl", "dgst", "-sha256", "-sign", cls.release_private, + "-out", release / "SHA256SUMS.sig", release / "SHA256SUMS", + ) + + @classmethod + def build(cls, release, version, output, *extra, ok=True): + return run( + sys.executable, SCRIPT, "build", "--release-dir", release, "--version", version, + "--output", output, "--signing-key", cls.fingerprint, "--gnupg-home", cls.gnupg, + "--release-public-key", cls.release_public, "--date", cls.date, *extra, ok=ok, + ) + + def setUp(self): + self.case_dir = self.root / self.id().split(".")[-1] + self.case_dir.mkdir(mode=0o755) + self.repo = self.case_dir / "repository" + shutil.copytree(self.base, self.repo) + + def verify(self, *extra, ok=True, key=None, fingerprint=None): + return run( + sys.executable, SCRIPT, "verify", "--repository", self.repo, + "--public-key", key or self.key, "--fingerprint", fingerprint or self.fingerprint, + *extra, ok=ok, + ) + + def rewrite_manifest(self, update_packages=False): + path = self.repo / "repository-manifest.json" + manifest = json.loads(path.read_text()) + for entry in manifest["files"]: + file = self.repo / entry["path"] + if file.is_file(): + entry.update(sha256=digest(file), size=file.stat().st_size) + if update_packages: + for package in manifest["packages"]: + file = self.repo / package["path"] + package.update(distributedSha256=digest(file), size=file.stat().st_size) + manifest.pop("revision", None) + encoded = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode() + manifest["revision"] = hashlib.sha256(encoded).hexdigest() + path.write_text(json.dumps(manifest)) + + def dnf_download(self, package="loopwire", *, key_url=None): + handler = functools.partial(QuietHandler, directory=str(self.repo)) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + state = self.case_dir / f"dnf-{time.time_ns()}" + repos = state / "repos" + downloads = state / "downloads" + for directory in (state, repos, downloads, state / "cache", state / "persist", state / "log"): + directory.mkdir(exist_ok=True) + base = f"http://127.0.0.1:{server.server_port}" + (repos / "loopwire.repo").write_text( + "[loopwire]\nname=Loopwire test\nenabled=1\n" + f"baseurl={base}/\ngpgkey={key_url or base + f'/keys/{self.fingerprint}.asc'}\n" + "gpgcheck=1\nrepo_gpgcheck=1\nmetadata_expire=0\nsslverify=1\n" + ) + command = [ + "dnf", "--assumeyes", "--setopt", f"reposdir={repos}", + "--setopt", f"cachedir={state / 'cache'}", "--setopt", f"persistdir={state / 'persist'}", + "--setopt", f"logdir={state / 'log'}", "--setopt", "optional_metadata_types=", + "--repo", "loopwire", "download", "--from-repo", "loopwire", + "--destdir", downloads, package, + ] + try: + return run(*command, ok=False), downloads + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_signed_chain_and_real_dnf_download(self): + source = self.release1 / "loopwire-1.0.0-1.fc44.x86_64.rpm" + source_before = digest(source) + summary = json.loads(self.verify().stdout) + self.assertEqual(summary["signingFingerprint"], self.fingerprint) + self.assertEqual(summary["target"], { + "distribution": "fedora", "release": "44", "architecture": "x86_64", + }) + package = summary["packages"][0] + self.assertEqual(package["sourceReleaseSha256"], source_before) + self.assertNotEqual(package["distributedSha256"], source_before) + self.assertEqual(digest(source), source_before, "generator mutated the source release RPM") + result, downloads = self.dnf_download("loopwire-1.0.0-1.fc44.x86_64") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + downloaded = downloads / "loopwire-1.0.0-1.fc44.x86_64.rpm" + self.assertEqual(digest(downloaded), package["distributedSha256"]) + + def test_retention_version_order_and_fresh_rollback(self): + upgraded = self.case_dir / "upgraded" + self.build(self.release2, "1.1.0", upgraded, "--previous", self.base) + old = json.loads((self.base / "repository-manifest.json").read_text()) + new = json.loads((upgraded / "repository-manifest.json").read_text()) + for entry in old["files"]: + if entry["kind"] == "immutable": + self.assertEqual(digest(upgraded / entry["path"]), entry["sha256"]) + self.assertEqual([package["version"] for package in new["packages"]], ["1.0.0", "1.1.0"]) + failed = self.build( + self.release1, "1.0.0", self.case_dir / "downgrade", "--previous", upgraded, ok=False, + ) + self.assertNotEqual(failed.returncode, 0) + rollback = self.case_dir / "rollback" + run( + sys.executable, SCRIPT, "rollback", "--repository", self.base, "--output", rollback, + "--signing-key", self.fingerprint, "--gnupg-home", self.gnupg, + "--date", self.date + 60, + ) + rolled = json.loads((rollback / "repository-manifest.json").read_text()) + self.assertEqual(rolled["packages"], old["packages"]) + self.assertEqual(rolled["createdAt"], self.date + 60) + self.assertNotEqual(rolled["revision"], old["revision"]) + run( + sys.executable, SCRIPT, "verify", "--repository", rollback, + "--public-key", self.key, "--fingerprint", self.fingerprint, "--now", self.date + 60, + ) + + def test_release_signature_checksum_and_extra_rpm_rejected(self): + release = self.case_dir / "release" + shutil.copytree(self.release1, release) + (release / "SHA256SUMS.sig").write_bytes(b"invalid") + self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / "bad", ok=False).returncode, 0) + self.sign_release(release) + checksums = release / "SHA256SUMS" + checksums.write_text(checksums.read_text() * 2) + run( + "openssl", "dgst", "-sha256", "-sign", self.release_private, + "-out", release / "SHA256SUMS.sig", checksums, + ) + self.assertNotEqual(self.build( + release, "1.0.0", self.case_dir / "duplicate", ok=False, + ).returncode, 0) + self.sign_release(release) + shutil.copyfile(next(release.glob("*.rpm")), release / "other.rpm") + self.sign_release(release) + self.assertNotEqual(self.build( + release, "1.0.0", self.case_dir / "extra", ok=False, + ).returncode, 0) + (release / "other.rpm").unlink() + shutil.copyfile(next(release.glob("loopwire-1.0.0-1.fc44.x86_64.rpm")), + release / "loopwire-1.0.0-1.x86_64.rpm") + self.sign_release(release) + self.build(release, "1.0.0", self.case_dir / "known-sibling") + + def test_deterministic_candidate_and_build_metadata_upgrade(self): + second = self.case_dir / "second" + self.build(self.release1, "1.0.0", second) + self.assertEqual( + (second / "repository-manifest.json").read_bytes(), + (self.base / "repository-manifest.json").read_bytes(), + ) + for entry in json.loads((self.base / "repository-manifest.json").read_text())["files"]: + self.assertEqual(digest(second / entry["path"]), entry["sha256"]) + release = self.make_release("1.0.0+rpmfixture1") + upgraded = self.case_dir / "upgraded" + self.build(release, "1.0.0+rpmfixture1", upgraded, "--previous", self.base) + self.repo = upgraded + result, downloads = self.dnf_download("loopwire") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue((downloads / "loopwire-1.0.0+rpmfixture1-1.fc44.x86_64.rpm").is_file()) + + def test_encrypted_signing_key_uses_protected_passphrase_file(self): + home = self.root / "encrypted-gnupg" + home.mkdir(mode=0o700) + passphrase = self.case_dir / "passphrase" + passphrase.write_text("fixture passphrase with spaces\n") + passphrase.chmod(0o600) + try: + run( + "gpg", "--homedir", home, "--batch", "--pinentry-mode", "loopback", + "--passphrase-file", passphrase, "--quick-generate-key", + "Encrypted RPM Fixture", "rsa2048", "sign", "0", + ) + listing = run("gpg", "--homedir", home, "--with-colons", "--list-keys").stdout + identity = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:")) + run("gpgconf", "--homedir", home, "--kill", "gpg-agent") + output = self.case_dir / "encrypted" + self.build( + self.release1, "1.0.0", output, "--gnupg-home", home, + "--signing-key", identity, "--passphrase-file", passphrase, + "--date", int(time.time()) + 1, + ) + run( + sys.executable, SCRIPT, "verify", "--repository", output, + "--public-key", output / f"keys/{identity}.asc", "--fingerprint", identity, + ) + passphrase.chmod(0o644) + self.assertNotEqual(self.build( + self.release2, "1.1.0", self.case_dir / "weak-secret", + "--gnupg-home", home, "--signing-key", identity, + "--passphrase-file", passphrase, ok=False, + ).returncode, 0) + finally: + run("gpgconf", "--homedir", home, "--kill", "gpg-agent", ok=False) + + def test_package_name_version_architecture_release_and_repack_rejected(self): + variants = [ + (dict(name="other"), "other"), + (dict(architecture="noarch"), "architecture"), + (dict(release="1.fc43"), "release"), + ] + for options, suffix in variants: + release = self.make_release("1.2.0", suffix=suffix, **options) + self.assertNotEqual(self.build( + release, "1.2.0", self.case_dir / suffix, ok=False, + ).returncode, 0) + self.assertNotEqual(self.build( + self.release1, "1.0.1", self.case_dir / "version-mismatch", ok=False, + ).returncode, 0) + repack = self.make_release("1.0.0", suffix="repack") + self.assertNotEqual(self.build( + repack, "1.0.0", self.case_dir / "repack", "--previous", self.base, ok=False, + ).returncode, 0) + + def test_wrong_repository_signer_rejected_by_verifier_and_dnf(self): + self.assertNotEqual(self.verify( + ok=False, fingerprint=self.wrong_fingerprint, + ).returncode, 0) + self.assertNotEqual(self.verify(ok=False, key=self.wrong_key).returncode, 0) + result, _downloads = self.dnf_download( + key_url=self.wrong_key.resolve().as_uri(), + ) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_repomd_tampering_and_missing_signature_rejected(self): + repomd = self.repo / "repodata/repomd.xml" + repomd.write_text(repomd.read_text().replace("Fedora 44", "Forged 44")) + self.rewrite_manifest() + self.assertNotEqual(self.verify(ok=False).returncode, 0) + result, _downloads = self.dnf_download() + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + (self.repo / "repodata/repomd.xml.asc").unlink() + result, _downloads = self.dnf_download() + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_modified_or_unsigned_rpm_rejected_by_verifier_and_dnf(self): + package = next(self.repo.glob("packages/*.rpm")) + package.write_bytes(package.read_bytes() + b"tampered") + self.rewrite_manifest(update_packages=True) + self.assertNotEqual(self.verify(ok=False).returncode, 0) + result, _downloads = self.dnf_download("loopwire-1.0.0-1.fc44.x86_64") + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + shutil.copytree(self.base, self.repo, dirs_exist_ok=True) + package = next(self.repo.glob("packages/*.rpm")) + run("rpmsign", "--delsign", package) + self.rewrite_manifest(update_packages=True) + self.assertNotEqual(self.verify(ok=False).returncode, 0) + + def test_tampered_or_incomplete_metadata_and_previous_snapshot_rejected(self): + primary = next(self.repo.glob("repodata/*-primary.xml.gz")) + primary.write_bytes(primary.read_bytes() + b"tampered") + self.rewrite_manifest() + self.assertNotEqual(self.verify(ok=False).returncode, 0) + self.assertNotEqual(self.build( + self.release2, "1.1.0", self.case_dir / "invalid-previous", + "--previous", self.repo, ok=False, + ).returncode, 0) + shutil.copytree(self.base, self.repo, dirs_exist_ok=True) + next(self.repo.glob("repodata/*-filelists.xml.gz")).unlink() + self.assertNotEqual(self.verify(ok=False).returncode, 0) + + def test_expired_future_and_forged_validity_rejected(self): + self.assertNotEqual(self.verify("--now", self.date + 31 * 86400, ok=False).returncode, 0) + self.assertNotEqual(self.verify("--now", self.date - 60, ok=False).returncode, 0) + manifest = json.loads((self.repo / "repository-manifest.json").read_text()) + manifest["validUntil"] += 365 * 86400 + manifest.pop("revision") + manifest["revision"] = hashlib.sha256(json.dumps( + manifest, sort_keys=True, separators=(",", ":"), + ).encode()).hexdigest() + (self.repo / "repository-manifest.json").write_text(json.dumps(manifest)) + self.assertNotEqual(self.verify("--now", self.date + 31 * 86400, ok=False).returncode, 0) + + def test_path_classification_extra_file_and_manifest_duplicates_rejected(self): + path = self.repo / "repository-manifest.json" + original = path.read_text() + for replacement in ("../../outside", "/absolute", "repodata/../secret", "packages//double"): + manifest = json.loads(original) + manifest["files"][0]["path"] = replacement + path.write_text(json.dumps(manifest)) + self.assertNotEqual(self.verify(ok=False).returncode, 0) + path.write_text(original) + manifest = json.loads(original) + manifest["files"][0]["kind"] = "metadata" + manifest.pop("revision") + manifest["revision"] = hashlib.sha256(json.dumps( + manifest, sort_keys=True, separators=(",", ":"), + ).encode()).hexdigest() + path.write_text(json.dumps(manifest)) + self.assertNotEqual(self.verify(ok=False).returncode, 0) + path.write_text(original) + (self.repo / "unadvertised").write_text("extra") + self.assertNotEqual(self.verify(ok=False).returncode, 0) + (self.repo / "unadvertised").unlink() + path.write_text(original.replace( + '"schema": "loopwire.rpm-repository.v1",', + '"schema": "duplicate",\n "schema": "loopwire.rpm-repository.v1",', + )) + self.assertNotEqual(self.verify(ok=False).returncode, 0) + + def test_symlink_hardlink_and_output_reuse_rejected(self): + package = next(self.repo.glob("packages/*.rpm")) + original = package.read_bytes() + package.unlink() + package.symlink_to(self.base / package.relative_to(self.repo)) + self.assertNotEqual(self.verify(ok=False).returncode, 0) + package.unlink() + package.write_bytes(original) + os.link(package, self.case_dir / "hardlink") + self.assertNotEqual(self.verify(ok=False).returncode, 0) + output = self.case_dir / "exists" + output.mkdir() + self.assertNotEqual(self.build( + self.release2, "1.1.0", output, ok=False, + ).returncode, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/verify-docs.sh b/scripts/verify-docs.sh index 713dae8..298475c 100644 --- a/scripts/verify-docs.sh +++ b/scripts/verify-docs.sh @@ -7,6 +7,7 @@ required_files=( "apps/docs/docs/index.md" "apps/docs/docs/guide/install.md" "apps/docs/docs/guide/apt-repository.md" + "apps/docs/docs/guide/fedora-repository.md" "apps/docs/docs/guide/basic-usage.md" "apps/docs/docs/guide/start-on-boot.md" "apps/docs/docs/guide/backends.md" @@ -19,6 +20,7 @@ required_files=( "apps/docs/docs/developer/vm-matrix.md" "apps/docs/docs/developer/release.md" "apps/docs/docs/developer/apt-repository.md" + "apps/docs/docs/developer/fedora-repository.md" "apps/docs/docs/developer/release-notes.md" "apps/docs/docs/release-notes/0.1.0.md" "apps/docs/docs/release-notes/unreleased.md" @@ -67,10 +69,16 @@ assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/troubleshooting" assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/basic-usage" assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/apt-repository" assert_contains "apps/docs/docs/.vitepress/config.ts" "/developer/apt-repository" +assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/fedora-repository" +assert_contains "apps/docs/docs/.vitepress/config.ts" "/developer/fedora-repository" assert_contains "apps/docs/docs/guide/apt-repository.md" "Signed-By" assert_contains "apps/docs/docs/guide/apt-repository.md" "--allow-downgrades" assert_contains "apps/docs/docs/developer/apt-repository.md" "APT_REPOSITORY_ENABLED" assert_contains "apps/docs/docs/developer/apt-repository.md" "Final activation is a human operation" +assert_contains "apps/docs/docs/guide/fedora-repository.md" "repo_gpgcheck=1" +assert_contains "apps/docs/docs/guide/fedora-repository.md" "sudo dnf downgrade loopwire" +assert_contains "apps/docs/docs/developer/fedora-repository.md" "FEDORA_REPOSITORY_ENABLED" +assert_contains "apps/docs/docs/developer/fedora-repository.md" "Final activation is a human operation" assert_contains "apps/docs/docs/.vitepress/config.ts" 'base: "/docs/"' assert_contains "apps/docs/docs/.vitepress/config.ts" "/developer/release-notes" assert_contains "apps/docs/docs/.vitepress/config.ts" "/release-notes/0.1.0" diff --git a/scripts/verify-fedora-repository-vm-proof.mjs b/scripts/verify-fedora-repository-vm-proof.mjs new file mode 100755 index 0000000..04e9111 --- /dev/null +++ b/scripts/verify-fedora-repository-vm-proof.mjs @@ -0,0 +1,456 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { lstat, mkdtemp, readFile, readdir, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repositoryRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const requiredPaths = [ + "/usr/bin/loopwire", "/usr/bin/loopwire-dsp-provider", "/usr/bin/loopwire-jack-ports", + "/usr/bin/loopwire-detect-audio", "/usr/lib/loopwire/loopwire-gui", + "/usr/share/applications/loopwire.desktop", "/usr/share/icons/hicolor/scalable/apps/loopwire.svg", +]; + +function requireThat(condition, message) { + if (!condition) throw new Error(message); +} +async function bytes(directory, name) { + const file = path.join(directory, name); + const stat = await lstat(file); + requireThat(stat.isFile() && !stat.isSymbolicLink(), `evidence must be a regular file: ${name}`); + return readFile(file); +} +async function text(directory, name, nonempty = true) { + const result = (await bytes(directory, name)).toString("utf8"); + requireThat(!nonempty || result.trim(), `empty evidence: ${name}`); + return result; +} +function tsvMap(value, label) { + const map = new Map(); + for (const line of value.trimEnd().split("\n")) { + const separator = line.indexOf("\t"); + requireThat(separator > 0, `${label} must contain key/value TSV`); + const key = line.slice(0, separator); + requireThat(!map.has(key), `${label} repeats ${key}`); + map.set(key, line.slice(separator + 1)); + } + return map; +} +function stageTable(value, label) { + const result = new Map(); + for (const line of value.trimEnd().split("\n")) { + const fields = line.split("\t"); + requireThat(fields.length === 3 && ["baseline", "upgraded"].includes(fields[0]), `invalid ${label} row`); + requireThat(!result.has(fields[0]), `${label} repeats ${fields[0]}`); + requireThat(/^loopwire-[0-9A-Za-z.+~_-]+-1\.fc44\.x86_64\.rpm$/.test(fields[1]), `invalid ${label} package name`); + requireThat(/^[a-f0-9]{64}$/.test(fields[2]), `invalid ${label} SHA-256`); + result.set(fields[0], { name: fields[1], sha256: fields[2] }); + } + requireThat(result.size === 2, `${label} must contain baseline and upgraded rows`); + return result; +} +function equal(actual, expected, label) { + requireThat(actual === expected, `${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +} +function command(program, args) { + const result = spawnSync(program, args, { encoding: "utf8", maxBuffer: 32 * 1024 * 1024 }); + requireThat(!result.error && result.status === 0, + `${program} verification failed: ${result.error?.message ?? result.stderr ?? result.stdout}`); + return result.stdout; +} +function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); } + +export function parseReleaseChecksums(value) { + const entries = new Map(); + for (const [index, line] of value.trimEnd().split("\n").entries()) { + const match = /^([a-f0-9]{64}) {2}([^/\\\s]+)$/.exec(line); + requireThat(match && !entries.has(match[2]), `invalid or duplicate signed checksum entry at line ${index + 1}`); + entries.set(match[2], match[1]); + } + return entries; +} + +export function verifyReleaseSignature(checksumsFile, signatureFile, publicKeyFile) { + command("openssl", ["dgst", "-sha256", "-verify", publicKeyFile, "-signature", signatureFile, checksumsFile]); +} + +export function verifyReleaseAssetManifest(value, expected) { + const manifest = JSON.parse(value); + assert.deepEqual(Object.keys(manifest).sort(), ["artifacts", "release", "schema"], "release manifest fields"); + equal(manifest.schema, "loopwire.release-assets.v1", "release manifest schema"); + assert.deepEqual(Object.keys(manifest.release).sort(), ["gitHead", "tag", "version"], "release identity fields"); + equal(manifest.release.tag, `v${expected.version}`, "public release tag"); + equal(manifest.release.version, expected.version, "public release version"); + requireThat(/^[a-f0-9]{40}$/.test(manifest.release.gitHead), "public release commit must be a full lowercase hash"); + requireThat(Array.isArray(manifest.artifacts), "release artifacts must be an array"); + const fedora = manifest.artifacts.filter((entry) => entry && entry.target === "fedora-44"); + equal(fedora.length, 1, "Fedora release artifact count"); + assert.deepEqual(Object.keys(fedora[0]).sort(), ["architecture", "bytes", "kind", "name", "sha256", "target"], + "Fedora release artifact fields"); + assert.deepEqual(fedora[0], { name: expected.rpmName, kind: "native-rpm", target: "fedora-44", + architecture: "x86_64", bytes: expected.rpmBytes, sha256: expected.rpmSha256 }, "Fedora release artifact"); + const portable = manifest.artifacts.filter((entry) => entry?.name === "loopwire-linux-x86_64.tar.gz"); + equal(portable.length, 1, "x86_64 portable release artifact count"); + for (const [key, wanted] of Object.entries({ kind: "portable-archive", target: "linux-generic", architecture: "x86_64", + bytes: expected.tarBytes, sha256: expected.tarSha256 })) equal(portable[0][key], wanted, `portable release artifact ${key}`); + return manifest; +} + +export function parsePayloadRelease(value, expectedVersion) { + const fields = new Map(); + for (const line of value.trimEnd().split("\n")) { + const separator = line.indexOf("="); + requireThat(separator > 0, "invalid RELEASE field"); + const key = line.slice(0, separator); + requireThat(!fields.has(key), `duplicate RELEASE field: ${key}`); + fields.set(key, line.slice(separator + 1)); + } + assert.deepEqual([...fields.keys()].sort(), ["arch", "name", "source_date_epoch", "version"], "RELEASE fields"); + equal(fields.get("name"), "loopwire", "RELEASE name"); + equal(fields.get("version"), expectedVersion, "RELEASE version"); + equal(fields.get("arch"), "x86_64", "RELEASE architecture"); + requireThat(/^[0-9]+$/.test(fields.get("source_date_epoch") ?? ""), "RELEASE source date epoch"); + return fields; +} + +export function parseInstalledHashes(value) { + const result = {}; + for (const line of value.trimEnd().split("\n")) { + const match = /^([a-f0-9]{64}) {2}(\/usr\/[^\r\n]+)$/.exec(line); + requireThat(match && !match[2].split("/").includes(".."), "invalid installed-file checksum record"); + requireThat(!Object.hasOwn(result, match[2]), `duplicate installed path: ${match[2]}`); + result[match[2]] = match[1]; + } + return result; +} + +export function verifyRpmSignature(value, fingerprint, packageName) { + requireThat(value.includes(packageName), "RPM signature output names the wrong package"); + requireThat(!/NOT OK|NOKEY|NOTTRUSTED|BAD|FAILED|UNSIGNED/i.test(value), "RPM signature verification failed"); + const normalized = value.toLowerCase(); + const fullFingerprint = fingerprint.toLowerCase(); + const keyId = fingerprint.slice(-16).toLowerCase(); + const shortKeyId = fingerprint.slice(-8).toLowerCase(); + requireThat(/(?:OpenPGP[^\n]* )?RSA\/SHA(?:256|512) signature/i.test(value), + "RPM lacks an RSA OpenPGP signature"); + requireThat(normalized.includes(`key fingerprint: ${fullFingerprint}`) || + normalized.includes(`key id ${keyId}`) || normalized.includes(`key id ${shortKeyId}`), + "RPM signature uses the wrong key"); + requireThat(/Signature[^\n]*:\s*OK/i.test(value), "RPM signature was not accepted"); +} + +export async function rpmPayload(packageFile) { + const bsdtar = spawnSync("bsdtar", ["--version"], { encoding: "utf8" }); + if (!bsdtar.error && bsdtar.status === 0) { + const listed = command("bsdtar", ["-tf", packageFile]).trimEnd().split("\n"); + for (const name of listed) { + const normalized = name.replace(/^\.\//, ""); + requireThat(normalized.startsWith("usr/") && !normalized.split("/").includes(".."), "unsafe RPM payload path"); + } + const temporary = await mkdtemp(path.join(tmpdir(), "loopwire-rpm-payload-")); + try { + command("bsdtar", ["--no-same-owner", "--no-same-permissions", "-xf", packageFile, "-C", temporary]); + const files = {}; + async function collect(directory) { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const item = path.join(directory, entry.name); + if (entry.isDirectory()) { + await collect(item); + continue; + } + if (!entry.isFile() && !entry.isSymbolicLink()) continue; + const resolved = await realpath(item); + requireThat(resolved.startsWith(`${temporary}${path.sep}`), "RPM payload link escapes extraction root"); + if (!(await lstat(resolved)).isFile()) continue; + const installed = `/${path.relative(temporary, item).split(path.sep).join("/")}`; + requireThat(!Object.hasOwn(files, installed), `duplicate RPM payload path: ${installed}`); + files[installed] = sha256(await readFile(item)); + } + } + await collect(path.join(temporary, "usr")); + return files; + } finally { + await rm(temporary, { recursive: true, force: true }); + } + } + const mount = path.dirname(packageFile); + return JSON.parse(command("bash", [path.join(repositoryRoot, "scripts/with-rpm-tools.sh"), + "--read-only-path", mount, "python3", "-c", String.raw` +import hashlib, json, pathlib, subprocess, sys, tempfile +package = pathlib.Path(sys.argv[1]) +listed = subprocess.run(['rpm', '-qlp', str(package)], check=True, capture_output=True, text=True).stdout.splitlines() +for name in listed: + pure = pathlib.PurePosixPath(name) + assert pure.is_absolute() and pure.parts[:2] == ('/', 'usr') and '..' not in pure.parts, 'unsafe package path' +with tempfile.TemporaryDirectory() as temporary: + root = pathlib.Path(temporary) + first = subprocess.Popen(['rpm2cpio', str(package)], stdout=subprocess.PIPE) + extracted = subprocess.run(['cpio', '--quiet', '-idm', '--no-absolute-filenames'], cwd=root, + stdin=first.stdout, capture_output=True, text=False) + first.stdout.close() + assert first.wait() == 0 and extracted.returncode == 0, extracted.stderr.decode(errors='replace') + files = {} + for name in listed: + item = root / name.lstrip('/') + if not item.is_file(): + continue + resolved = item.resolve(strict=True) + assert resolved.is_relative_to(root), 'package link escapes extraction root' + assert name not in files, 'duplicate package path' + files[name] = hashlib.sha256(item.read_bytes()).hexdigest() + print(json.dumps(files, sort_keys=True)) +`, packageFile])); +} + +export function verifyLifecycle(value, baselineVersion, upgradeVersion) { + equal(value.trimEnd(), [ + `install\t${baselineVersion}\tinstalled`, `reinstall\t${baselineVersion}\tinstalled`, + `upgrade\t${upgradeVersion}\tinstalled`, `rollback\t${baselineVersion}\tinstalled`, + `remove\t${baselineVersion}\tabsent`, + ].join("\n"), "lifecycle transitions"); +} + +export function verifyPackageEntry(entry, expected, packageSha256, sourceSha256, label) { + requireThat(entry && typeof entry === "object" && !Array.isArray(entry), `${label} package entry missing`); + equal(entry.name, "loopwire", `${label} package name`); + equal(entry.version, expected.version, `${label} package version`); + equal(entry.release, "1.fc44", `${label} package release`); + equal(entry.architecture, "x86_64", `${label} package architecture`); + equal(entry.path, `packages/${expected.name}`, `${label} package path`); + equal(entry.sourceReleaseSha256, sourceSha256, `${label} source release hash`); + equal(entry.distributedSha256, packageSha256, `${label} distributed RPM hash`); + requireThat(Number.isSafeInteger(entry.size) && entry.size > 0, `${label} package size missing`); +} + +export async function verifyInstalledStage(directory, stage, version, fingerprint, packageName, packageSha256, payloadHashes) { + const prefix = `${stage}/`; + equal((await text(directory, `${prefix}package-metadata.tsv`)).trim(), + `loopwire\t${version}\tx86_64`, `${stage} package metadata`); + equal((await text(directory, `${prefix}dnf-origin.txt`)).trim(), + `loopwire|${version}|x86_64|loopwire`, `${stage} repository origin`); + const info = await text(directory, `${prefix}dnf-info.txt`); + requireThat(/^From repository\s*:\s*loopwire$/m.test(info), `${stage} lacks DNF repository origin`); + const files = (await text(directory, `${prefix}package-files.txt`)).trim().split("\n"); + for (const installed of requiredPaths) requireThat(files.includes(installed), `${stage} missing ${installed}`); + const hashes = parseInstalledHashes(await text(directory, `${prefix}installed-files.sha256`)); + assert.deepEqual(hashes, payloadHashes, `${stage} installed bytes must equal the signed repository RPM payload`); + equal((await text(directory, `${prefix}signed-package.sha256`)).trim(), `${packageSha256} ${packageName}`, + `${stage} signed RPM digest`); + verifyRpmSignature(await text(directory, `${prefix}rpm-signature.txt`), fingerprint, packageName); + for (const help of ["background-help.txt", "dsp-provider-help.txt", "jack-provider-help.txt"]) { + await text(directory, `${prefix}${help}`); + } + const detection = JSON.parse(await text(directory, `${prefix}detect-audio.json`)); + requireThat(detection && typeof detection === "object", `${stage} detection must be JSON object/array`); + const linkage = await text(directory, `${prefix}gui-ldd.txt`); + requireThat(!linkage.includes("not found") && /libgtk-3/.test(linkage) && /libwebkit2gtk/.test(linkage), + `${stage} GUI linkage missing or unresolved`); + equal((await text(directory, `${prefix}gui-launch-status.txt`)).trim(), "0", `${stage} GUI launch`); + requireThat(/^\d+(?:\n\d+)*\n?$/.test(await text(directory, `${prefix}gui-window-ids.txt`)), `${stage} lacks X11 window ids`); + const names = (await text(directory, `${prefix}gui-window-names.txt`)).trim().split("\n"); + requireThat(names.every((name) => /^(Loopwire|loopwire-gui)$/.test(name)), `${stage} lacks Loopwire application window`); + const launch = await text(directory, `${prefix}gui-launch.log`, false); + requireThat(!/error while loading shared libraries|panic|protocol error|missing acquire timeline/i.test(launch), `${stage} fatal GUI log`); + await text(directory, `${prefix}xvfb.log`, false); +} + +export async function verifyEvidence({ target, evidenceDir, gitHead }) { + equal(target, "fedora-44", "supported target"); + requireThat(/^[a-f0-9]{40}$/.test(gitHead ?? ""), "--git-head must be a full lowercase commit hash"); + const summary = tsvMap(await text(evidenceDir, "summary.tsv"), "summary"); + equal(summary.get("schema"), "loopwire.fedora-repository-vm-proof.v1", "schema"); + equal(summary.get("target"), target, "target"); + equal(summary.get("git_head"), gitHead, "summary commit"); + equal((await text(evidenceDir, "git-head.txt")).trim(), gitHead, "evidence commit"); + equal(summary.get("payload_kind"), "public-release-baseline-with-synthetic-upgrade", "payload provenance kind"); + equal(summary.get("synthetic_upgrade"), "true", "synthetic fixture disclosure"); + const version = summary.get("version"); + requireThat(/^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:\+[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?$/.test(version ?? ""), "invalid baseline version"); + const upgradedVersion = `${version}${version.includes("+") ? "." : "+"}dnffixture1`; + equal(summary.get("upgrade_version"), upgradedVersion, "synthetic fixture version"); + const baselineVersion = `${version}-1.fc44`; + const upgradeVersion = `${upgradedVersion}-1.fc44`; + equal(summary.get("baseline_package_version"), baselineVersion, "baseline package version"); + equal(summary.get("upgrade_package_version"), upgradeVersion, "upgrade package version"); + const fingerprint = summary.get("fingerprint"); + requireThat(/^[A-F0-9]{40}$/.test(fingerprint ?? ""), "invalid signing fingerprint"); + const baseUrl = summary.get("base_url"); + equal(baseUrl, "https://127.0.0.1:8444/fedora/44/x86_64", "guest-only HTTPS origin"); + const epoch = summary.get("verification_epoch"); + requireThat(/^[0-9]{10}$/.test(epoch ?? "") && Number(epoch) <= Date.now() / 1000 + 300, "invalid proof timestamp"); + const os = new Map((await text(evidenceDir, "os-release")).split("\n").filter((line) => /^[A-Z_]+=/.test(line)).map((line) => { + const separator = line.indexOf("="); + return [line.slice(0, separator), line.slice(separator + 1).replace(/^"|"$/g, "")]; + })); + equal(os.get("ID"), "fedora", "guest OS"); + equal(os.get("VERSION_ID"), "44", "guest OS version"); + requireThat(["kvm", "qemu"].includes((await text(evidenceDir, "virtualization.txt")).trim()), "not a VM proof"); + requireThat((await text(evidenceDir, "uname.txt")).includes("Linux"), "guest kernel evidence missing"); + await text(evidenceDir, "console.log"); + const targetRows = (await readFile(path.join(repositoryRoot, "packaging/vm/native-package-targets.tsv"), "utf8")) + .split("\n").map((line) => line.split("\t")).filter((row) => row[0] === target); + requireThat(targetRows.length === 1, "target missing or duplicate in image manifest"); + const row = targetRows[0]; + const image = tsvMap(await text(evidenceDir, "image.tsv"), "image"); + for (const [key, expected] of Object.entries({ schema: "loopwire.native-package-image.v1", target, + distro: row[1], url: row[4], checksum_algorithm: row[5], checksum: row[6], actual_checksum: row[6], firmware: row[8] })) { + equal(image.get(key), expected, `image ${key}`); + } + equal((await text(evidenceDir, "initial-package-status.txt")).trim(), "absent", "clean guest status"); + requireThat(/^[a-f0-9]{64} {2}loopwire-linux-x86_64\.tar\.gz\n?$/.test(await text(evidenceDir, "release-payload.sha256")), + "missing original payload digest"); + const publicDirectory = path.join(evidenceDir, "public-release"); + const publicInventory = (await readdir(publicDirectory)).sort(); + assert.deepEqual(publicInventory, ["RELEASE", "SHA256SUMS", "SHA256SUMS.sig", + `loopwire-${baselineVersion}.x86_64.rpm`, "loopwire-linux-x86_64.tar.gz", "release-assets.json", + "release-signing-public.pem"].sort(), "public release evidence inventory"); + equal(await text(publicDirectory, "release-signing-public.pem"), + await readFile(path.join(repositoryRoot, "packaging/release-signing-public.pem"), "utf8"), "committed release signing key"); + verifyReleaseSignature(path.join(publicDirectory, "SHA256SUMS"), path.join(publicDirectory, "SHA256SUMS.sig"), + path.join(repositoryRoot, "packaging/release-signing-public.pem")); + const releaseChecksums = parseReleaseChecksums(await text(publicDirectory, "SHA256SUMS")); + const publicRpmName = `loopwire-${baselineVersion}.x86_64.rpm`; + const publicRpm = await bytes(publicDirectory, publicRpmName); + const publicTar = await bytes(publicDirectory, "loopwire-linux-x86_64.tar.gz"); + const publicManifest = await bytes(publicDirectory, "release-assets.json"); + for (const [name, content] of [[publicRpmName, publicRpm], ["loopwire-linux-x86_64.tar.gz", publicTar], + ["release-assets.json", publicManifest]]) { + requireThat(releaseChecksums.has(name), `signed checksums lack ${name}`); + equal(releaseChecksums.get(name), sha256(content), `signed public release hash for ${name}`); + } + const releaseManifest = verifyReleaseAssetManifest(publicManifest.toString("utf8"), { + version, rpmName: publicRpmName, rpmBytes: publicRpm.length, rpmSha256: sha256(publicRpm), + tarBytes: publicTar.length, tarSha256: sha256(publicTar), + }); + parsePayloadRelease(await text(publicDirectory, "RELEASE"), version); + equal(await text(evidenceDir, "payload-release.txt"), await text(publicDirectory, "RELEASE"), "captured RELEASE data"); + equal(summary.get("public_release_git_head"), releaseManifest.release.gitHead, "summary public release commit"); + equal((await text(evidenceDir, "public-release-git-head.txt")).trim(), releaseManifest.release.gitHead, + "captured public release commit"); + + const source = await text(evidenceDir, "loopwire.repo"); + for (const line of ["[loopwire]", `baseurl=${baseUrl}`, "enabled=1", "gpgcheck=1", "repo_gpgcheck=1", "sslverify=1", + `gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-loopwire-${fingerprint}`]) { + requireThat(source.split("\n").includes(line), `DNF source lacks ${line}`); + } + requireThat(!/sslverify\s*=\s*0|gpgcheck\s*=\s*0|repo_gpgcheck\s*=\s*0|skip_if_unavailable\s*=\s*True/i.test(source), + "DNF proof bypasses repository authentication or availability failures"); + equal(await text(evidenceDir, "https-key.asc"), await text(evidenceDir, "repository-key.asc"), "HTTPS public key"); + equal(await text(evidenceDir, "configured-repository-key.asc"), await text(evidenceDir, "repository-key.asc"), "configured public key"); + command("openssl", ["verify", "-attime", epoch, "-CAfile", path.join(evidenceDir, "tls-ca.crt"), + "-verify_ip", "127.0.0.1", path.join(evidenceDir, "tls-server.crt")]); + + const sources = stageTable(await text(evidenceDir, "release-sources.tsv"), "release sources"); + const signed = stageTable(await text(evidenceDir, "signed-packages.tsv"), "signed packages"); + const expectedNames = { + baseline: `loopwire-${baselineVersion}.x86_64.rpm`, + upgraded: `loopwire-${upgradeVersion}.x86_64.rpm`, + }; + equal(sources.get("baseline").sha256, sha256(publicRpm), "baseline source must be the public release RPM"); + equal(summary.get("baseline_source_sha256"), sha256(publicRpm), "summary public baseline source hash"); + for (const stage of ["baseline", "upgraded"]) { + equal(sources.get(stage).name, expectedNames[stage], `${stage} source RPM name`); + equal(signed.get(stage).name, expectedNames[stage], `${stage} signed RPM name`); + equal(summary.get(`${stage === "upgraded" ? "upgrade" : stage}_source_sha256`), sources.get(stage).sha256, + `${stage} summary source hash`); + equal(summary.get(`${stage === "upgraded" ? "upgrade" : stage}_rpm_sha256`), signed.get(stage).sha256, + `${stage} summary signed hash`); + equal(sha256(await bytes(evidenceDir, `packages/${expectedNames[stage]}`)), signed.get(stage).sha256, + `${stage} signed RPM evidence hash`); + } + const packageNames = (await readdir(path.join(evidenceDir, "packages"))).sort(); + assert.deepEqual(packageNames, ["baseline-rpm-signature.txt", expectedNames.baseline, + expectedNames.upgraded, "upgraded-rpm-signature.txt"].sort(), "package evidence inventory"); + verifyRpmSignature(await text(evidenceDir, "packages/baseline-rpm-signature.txt"), fingerprint, expectedNames.baseline); + verifyRpmSignature(await text(evidenceDir, "packages/upgraded-rpm-signature.txt"), fingerprint, expectedNames.upgraded); + const payloadHashes = { + [baselineVersion]: await rpmPayload(path.join(evidenceDir, "packages", expectedNames.baseline)), + [upgradeVersion]: await rpmPayload(path.join(evidenceDir, "packages", expectedNames.upgraded)), + }; + assert.deepEqual(await rpmPayload(path.join(publicDirectory, publicRpmName)), payloadHashes[baselineVersion], + "repository signing must preserve the public release RPM payload"); + + for (const stage of ["initial", "upgraded", "rolled-back"]) { + const directory = path.join(evidenceDir, "repositories", stage); + const result = command("bash", [path.join(repositoryRoot, "scripts/with-rpm-tools.sh"), "--read-only-path", evidenceDir, + "python3", path.join(repositoryRoot, "scripts/rpm-repository.py"), "verify", "--repository", directory, + "--public-key", path.join(evidenceDir, "repository-key.asc"), "--fingerprint", fingerprint, "--now", epoch]); + JSON.parse(result); + JSON.parse(await text(evidenceDir, `repositories/${stage}-verification.json`)); + const manifest = JSON.parse(await text(directory, "repository-manifest.json")); + equal(manifest.schema, "loopwire.rpm-repository.v1", `${stage} repository schema`); + equal(manifest.schemaVersion, 1, `${stage} repository schema version`); + assert.deepEqual(manifest.target, { distribution: "fedora", release: "44", architecture: "x86_64" }, + `${stage} repository target`); + const served = JSON.parse(await text(evidenceDir, `repositories/${stage}-public-verification.json`)); + equal(served.status, "verified", `${stage} HTTPS verification`); + equal(served.revision, manifest.revision, `${stage} HTTPS revision`); + equal(served.files, manifest.files.length + 1, `${stage} HTTPS file count including manifest`); + const expectedVersions = stage === "upgraded" ? [baselineVersion, upgradeVersion] : [baselineVersion]; + equal(manifest.packages.length, expectedVersions.length, `${stage} package count`); + for (const expectedVersion of expectedVersions) { + const fixtureStage = expectedVersion === baselineVersion ? "baseline" : "upgraded"; + const matches = manifest.packages.filter((entry) => entry.name === "loopwire" && + `${entry.version}-${entry.release}` === expectedVersion); + requireThat(matches.length === 1, `${stage} must contain exactly one ${expectedVersion} package`); + verifyPackageEntry(matches[0], { version: expectedVersion.replace(/-1\.fc44$/, ""), name: expectedNames[fixtureStage] }, + signed.get(fixtureStage).sha256, sources.get(fixtureStage).sha256, `${stage} ${expectedVersion}`); + } + if (stage !== "upgraded") requireThat(!manifest.packages.some((entry) => entry.version === upgradedVersion), + `${stage} unexpectedly advertises upgrade`); + } + + verifyLifecycle(await text(evidenceDir, "lifecycle.tsv"), baselineVersion, upgradeVersion); + for (const [stage, expectedVersion] of Object.entries({ + install: baselineVersion, reinstall: baselineVersion, upgrade: upgradeVersion, rollback: baselineVersion, + })) { + const fixtureStage = expectedVersion === baselineVersion ? "baseline" : "upgraded"; + await verifyInstalledStage(evidenceDir, stage, expectedVersion, fingerprint, expectedNames[fixtureStage], + signed.get(fixtureStage).sha256, payloadHashes[expectedVersion]); + const log = await text(evidenceDir, `${stage}.log`); + requireThat(log.includes("loopwire") && log.includes(expectedVersion), `${stage} lacks DNF operation/version log`); + } + const commands = await text(evidenceDir, "commands.log"); + for (const needle of ["dnf install -y loopwire-", "dnf reinstall -y", "dnf upgrade -y loopwire", + "dnf downgrade -y", "dnf remove -y loopwire", " -Kv ", "smoke_installed", "xdotool"]) { + requireThat(commands.includes(needle), `missing executed command: ${needle}`); + } + for (const file of ["bootstrap.log", "bootstrap-makecache.log", "upgrade-makecache.log", "rollback-makecache.log", + "remove.log", "source-removal.log", "source-removal-clean.log"]) await text(evidenceDir, file); + const requests = await text(evidenceDir, "https-server.log"); + for (const requested of [`/fedora/44/x86_64/keys/${fingerprint}.asc`, "/fedora/44/x86_64/repodata/repomd.xml", + "/fedora/44/x86_64/repodata/repomd.xml.asc", `/fedora/44/x86_64/packages/${expectedNames.baseline}`, + `/fedora/44/x86_64/packages/${expectedNames.upgraded}`]) { + requireThat(requests.includes(requested), `missing real HTTPS request: ${requested}`); + } + const removal = tsvMap(await text(evidenceDir, "removed-files.tsv"), "removed paths"); + for (const removed of [...requiredPaths.filter((value) => value !== "/usr/lib/loopwire/loopwire-gui"), "/usr/lib/loopwire"]) { + equal(removal.get(removed), "absent", `removed ${removed}`); + } + requireThat(!/(^|\s)loopwire(\s|$)/m.test(await text(evidenceDir, "source-removal-repositories.txt")), + "removed DNF source remains active"); + return { target, gitHead, baselineVersion, upgradeVersion, fingerprint, + packageHashes: { [baselineVersion]: signed.get("baseline").sha256, [upgradeVersion]: signed.get("upgraded").sha256 } }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const args = {}; + for (let index = 2; index < process.argv.length; index += 2) { + const option = process.argv[index]; + requireThat(["--target", "--evidence-dir", "--git-head"].includes(option) && process.argv[index + 1], `invalid option: ${option}`); + requireThat(!Object.hasOwn(args, option), `duplicate option: ${option}`); + args[option] = process.argv[index + 1]; + } + requireThat(args["--evidence-dir"], "--evidence-dir is required"); + const result = await verifyEvidence({ target: args["--target"], evidenceDir: path.resolve(args["--evidence-dir"]), gitHead: args["--git-head"] }); + console.log(`Fedora repository VM proof verified: ${result.target}`); + console.log(JSON.stringify(result)); + } catch (error) { + console.error(`verify-fedora-repository-vm-proof: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/verify-github-workflows.sh b/scripts/verify-github-workflows.sh index 2c072d5..51e2f6a 100755 --- a/scripts/verify-github-workflows.sh +++ b/scripts/verify-github-workflows.sh @@ -96,6 +96,7 @@ fi workflows=( ".github/workflows/ci.yml" ".github/workflows/publish-apt.yml" + ".github/workflows/publish-fedora.yml" ".github/workflows/web.yml" ".github/workflows/aur.yml" ".github/workflows/workflow-checks.yml" @@ -337,6 +338,7 @@ assert_contains ".github/workflows/vm-matrix.yml" "apps/docs/docs/guide/support- ruby "$root/scripts/test-ci-impact.rb" ruby "$root/scripts/test-ci-workflow-paths.rb" ruby "$root/scripts/test-apt-workflow.rb" +ruby "$root/scripts/test-fedora-workflow.rb" node "$root/scripts/test-native-package-proof-snapshot.mjs" echo "GitHub workflow contract verification passed." diff --git a/scripts/verify-requirements.sh b/scripts/verify-requirements.sh index 7dce409..dbe75a9 100644 --- a/scripts/verify-requirements.sh +++ b/scripts/verify-requirements.sh @@ -124,8 +124,9 @@ done assert_script "package.json" "check" "pnpm check:verify && pnpm lint && pnpm typecheck && pnpm test && pnpm build && pnpm verify:site" assert_script "package.json" "check:verify" \ - "pnpm verify:requirements && pnpm verify:docs && pnpm test:setup-github && pnpm verify:scripts && pnpm verify:workflows && pnpm verify:runtime && pnpm verify:tauri && pnpm verify:apt" + "pnpm verify:requirements && pnpm verify:docs && pnpm test:setup-github && pnpm verify:scripts && pnpm verify:workflows && pnpm verify:runtime && pnpm verify:tauri && pnpm verify:apt && pnpm verify:rpm-repository" assert_script "package.json" "verify:apt" "bash scripts/verify-apt-repository.sh" +assert_script "package.json" "verify:rpm-repository" "bash scripts/verify-rpm-repository.sh" assert_script "package.json" "verify:requirements" "bash scripts/verify-requirements.sh" assert_script "package.json" "setup:github" "node scripts/setup-github-actions.mjs" assert_script "package.json" "test:setup-github" "node scripts/test-setup-github-actions.mjs" diff --git a/scripts/verify-rpm-public.py b/scripts/verify-rpm-public.py new file mode 100755 index 0000000..6a167a0 --- /dev/null +++ b/scripts/verify-rpm-public.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""Verify a served Fedora repository before producing its website activation record.""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +import ssl +import subprocess +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request + + +class NoRedirects(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, new_url): + response.close() + raise ValueError("repository verification does not follow redirects; use the canonical HTTPS URL") + + +def validate_base_url(value): + url = urllib.parse.urlsplit(value) + if (url.scheme != "https" or not url.hostname or url.username or url.password + or any(char in value for char in "\\'\"`$<>?#") + or any(ord(char) <= 32 or ord(char) >= 127 for char in value)): + raise ValueError("base URL must be HTTPS without credentials, whitespace, query, fragment, or shell metacharacters") + if url.port is not None and not 1 <= url.port <= 65535: + raise ValueError("invalid HTTPS port in base URL") + return value.rstrip("/") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repository", required=True, type=Path) + parser.add_argument("--public-key", required=True, type=Path) + parser.add_argument("--fingerprint", required=True) + parser.add_argument("--base-url", required=True) + parser.add_argument("--ca-file", type=Path, help="custom CA for isolated test servers") + parser.add_argument("--proof-url", help="GitHub Actions run URL required for --output") + parser.add_argument("--output", type=Path, help="write verified channel configuration after all checks") + args = parser.parse_args() + base_url = validate_base_url(args.base_url) + if args.output and args.ca_file: + raise ValueError("custom-CA fixture checks cannot produce public activation records") + if args.output and (not args.proof_url or not re.fullmatch( + r"https://github\.com/sandwichfarm/loopwire/actions/runs/[1-9][0-9]*", args.proof_url)): + raise ValueError("activation output requires the verifying project GitHub Actions run URL") + fingerprint = args.fingerprint.upper() + if not re.fullmatch(r"[A-F0-9]{40}", fingerprint): + raise ValueError("a complete OpenPGP fingerprint is required") + subprocess.run([ + sys.executable, str(Path(__file__).with_name("rpm-repository.py")), "verify", + "--repository", str(args.repository), "--public-key", str(args.public_key), + "--fingerprint", fingerprint, + ], check=True, stdout=subprocess.PIPE) + manifest_path = args.repository / "repository-manifest.json" + manifest = json.loads(manifest_path.read_text()) + if manifest.get("target") != {"distribution": "fedora", "release": "44", "architecture": "x86_64"}: + raise ValueError("candidate does not target Fedora 44 x86_64") + manifest_bytes = manifest_path.read_bytes() + entries = [*manifest["files"], { + "path": "repository-manifest.json", "size": len(manifest_bytes), + "sha256": hashlib.sha256(manifest_bytes).hexdigest(), + }] + context = ssl.create_default_context(cafile=str(args.ca_file) if args.ca_file else None) + opener = urllib.request.build_opener(NoRedirects(), urllib.request.HTTPSHandler(context=context)) + for entry in entries: + url = base_url + "/" + urllib.parse.quote(entry["path"], safe="/+") + request = urllib.request.Request(url, headers={"Cache-Control": "no-cache", "User-Agent": "Loopwire-RPM-Proof/1"}) + try: + response = opener.open(request, timeout=30) + except urllib.error.HTTPError as error: + status = error.code + error.close() + raise ValueError(f"repository returned HTTP {status} for {entry['path']}") from None + digest, size = hashlib.sha256(), 0 + with response: + while chunk := response.read(1024 * 1024): + size += len(chunk) + if size > entry["size"]: + raise ValueError(f"public file is larger than expected: {entry['path']}") + digest.update(chunk) + if size != entry["size"] or digest.hexdigest() != entry["sha256"]: + raise ValueError(f"public file differs from the verified candidate: {entry['path']}") + record = { + "schemaVersion": 1, "status": "verified", "target": "fedora-44", + "baseUrl": base_url, "signingFingerprint": fingerprint, "revision": manifest["revision"], + "verifiedAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "proofUrl": args.proof_url, + } + if args.output: + args.output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(mode="w", dir=args.output.parent, delete=False) as temporary: + json.dump(record, temporary, indent=2) + temporary.write("\n") + temporary_path = Path(temporary.name) + temporary_path.replace(args.output) + print(json.dumps({"status": "verified", "revision": manifest["revision"], "files": len(entries)})) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, KeyError, subprocess.CalledProcessError, urllib.error.URLError) as error: + print(f"verify-rpm-public: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/verify-rpm-repository.sh b/scripts/verify-rpm-repository.sh new file mode 100755 index 0000000..9dec45d --- /dev/null +++ b/scripts/verify-rpm-repository.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [ "${1:-}" != --inside ]; then + exec bash "$root/scripts/with-rpm-tools.sh" --container bash "$root/scripts/verify-rpm-repository.sh" --inside +fi +cd "$root" +export PYTHONDONTWRITEBYTECODE=1 +bash -n scripts/setup-fedora-repository.sh scripts/publish-fedora-workflow.sh \ + scripts/with-rpm-tools.sh packaging/vm/guest-fedora-repository-smoke.sh +node --check scripts/verify-fedora-repository-vm-proof.mjs +node --check scripts/test-fedora-repository-vm-proof.mjs +python3 scripts/test-rpm-repository.py +python3 scripts/test-publish-rpm-repository.py --with-ssh +python3 scripts/test-fedora-bootstrap.py +python3 scripts/test-rpm-public.py +python3 scripts/test-fedora-workflow-preflight.py +node scripts/test-fedora-repository-vm-proof.mjs +node --test apps/site/src/lib/rpmChannel.test.mjs +echo 'Fedora repository development verification passed.' diff --git a/scripts/verify-scripts.sh b/scripts/verify-scripts.sh index bc29593..270f81d 100755 --- a/scripts/verify-scripts.sh +++ b/scripts/verify-scripts.sh @@ -103,6 +103,10 @@ node --check scripts/verify-native-package-vm-proof.mjs node --check scripts/verify-native-package-proof-snapshot.mjs node --check scripts/verify-apt-repository-vm-proof.mjs node --check scripts/test-apt-repository-vm-proof.mjs +node --check scripts/verify-fedora-repository-vm-proof.mjs +node --check scripts/test-fedora-repository-vm-proof.mjs +node scripts/test-fedora-repository-vm-proof.mjs +bash -n packaging/vm/guest-fedora-repository-smoke.sh bash scripts/build-portable-linux-binary.sh -- --help | grep -Fq -- "--output FILE" || { echo "verify-scripts: portable builder does not accept the package-script separator" >&2 exit 1 diff --git a/scripts/with-rpm-tools.sh b/scripts/with-rpm-tools.sh new file mode 100755 index 0000000..31ce37d --- /dev/null +++ b/scripts/with-rpm-tools.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +image="${LOOPWIRE_RPM_TOOLS_IMAGE:-loopwire-rpm-tools:fedora-44}" +force_container=false +mounts=() +while [ "$#" -gt 0 ]; do + case "$1" in + --container) force_container=true; shift ;; + --read-only-path) + input="$(realpath -e "${2:?missing --read-only-path value}")" + mounts+=(--volume "$input:$input:ro") + shift 2 + ;; + *) break ;; + esac +done +[ "$#" -gt 0 ] || { echo 'Usage: with-rpm-tools.sh [--container] [--read-only-path PATH] COMMAND [ARG ...]' >&2; exit 2; } +available=true +for command in createrepo_c dnf gpg gpgv openssl python3 rpm rpmkeys rpmsign; do + command -v "$command" >/dev/null 2>&1 || available=false +done +export PYTHONDONTWRITEBYTECODE=1 +if [ "$available" = true ] && [ "$force_container" = false ]; then + exec "$@" +fi +command -v docker >/dev/null 2>&1 || { echo 'RPM repository tools or Docker are required; see the Fedora repository guide.' >&2; exit 1; } +if ! docker image inspect "$image" >/dev/null 2>&1; then + docker build --file "$root/packaging/repositories/Dockerfile.rpm-tools" --tag "$image" "$root" >&2 +fi +exec docker run --rm --network none --env PYTHONDONTWRITEBYTECODE=1 \ + --volume "$root:$root:ro" "${mounts[@]}" --workdir "$root" "$image" "$@"