diff --git a/.github/workflows/publish-opensuse.yml b/.github/workflows/publish-opensuse.yml new file mode 100644 index 0000000..0cd1cc2 --- /dev/null +++ b/.github/workflows/publish-opensuse.yml @@ -0,0 +1,84 @@ +name: Publish openSUSE 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: "17 6 * * 1" + +permissions: + contents: read + +concurrency: + group: opensuse-repository-production + cancel-in-progress: false + +jobs: + publish: + if: >- + ${{ + vars.OPENSUSE_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: 40 + environment: packages-production + container: + image: opensuse/tumbleweed@sha256:b6821dbfad5422b663e0eeae8241a30ef2976e1e9ae4a2f827641c0eecf7e4fd + steps: + - name: Install repository and workflow tools + run: >- + zypper --non-interactive --gpg-auto-import-keys refresh && + zypper --non-interactive install + createrepo_c gh git gpg2 nodejs24 openssh openssl python3 rpm-build + + - 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 }} + OPENSUSE_REPOSITORY_URL: ${{ vars.OPENSUSE_REPOSITORY_URL }} + OPENSUSE_REPOSITORY_HOST: ${{ vars.OPENSUSE_REPOSITORY_HOST }} + OPENSUSE_REPOSITORY_ROOT: ${{ vars.OPENSUSE_REPOSITORY_ROOT }} + OPENSUSE_SSH_PORT: ${{ vars.OPENSUSE_SSH_PORT || '22' }} + OPENSUSE_SIGNING_FINGERPRINT: ${{ vars.OPENSUSE_SIGNING_FINGERPRINT }} + OPENSUSE_SSH_PRIVATE_KEY: ${{ secrets.OPENSUSE_SSH_PRIVATE_KEY }} + OPENSUSE_SSH_KNOWN_HOSTS: ${{ secrets.OPENSUSE_SSH_KNOWN_HOSTS }} + OPENSUSE_SIGNING_KEY: ${{ secrets.OPENSUSE_SIGNING_KEY }} + OPENSUSE_SIGNING_PASSPHRASE: ${{ secrets.OPENSUSE_SIGNING_PASSPHRASE }} + run: bash scripts/publish-opensuse-workflow.sh + + - name: Upload public verification and activation record + uses: actions/upload-artifact@v7.0.1 + with: + name: loopwire-opensuse-publication-${{ github.run_id }} + path: dist/opensuse-publication + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b6d8443..11e05fe 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -523,3 +523,13 @@ jobs: tag: ${{ needs.publish-release.outputs.tag }} operation: publish secrets: inherit + + publish-opensuse: + name: Publish signed openSUSE channel + needs: publish-release + if: ${{ vars.OPENSUSE_REPOSITORY_ENABLED == 'true' && !contains(needs.publish-release.outputs.tag, '-') }} + uses: ./.github/workflows/publish-opensuse.yml + with: + tag: ${{ needs.publish-release.outputs.tag }} + operation: publish + secrets: inherit diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml index 5d34f1b..24c4d29 100644 --- a/.github/workflows/workflow-checks.yml +++ b/.github/workflows/workflow-checks.yml @@ -9,6 +9,7 @@ on: - "scripts/test-ci-workflow-paths.rb" - "scripts/test-apt-workflow.rb" - "scripts/test-fedora-workflow.rb" + - "scripts/test-opensuse-workflow.rb" - "scripts/*native-package-proof-snapshot.mjs" - "scripts/verify-github-workflows.sh" - "scripts/verify-requirements.sh" @@ -25,6 +26,7 @@ on: - "scripts/test-ci-workflow-paths.rb" - "scripts/test-apt-workflow.rb" - "scripts/test-fedora-workflow.rb" + - "scripts/test-opensuse-workflow.rb" - "scripts/*native-package-proof-snapshot.mjs" - "scripts/verify-github-workflows.sh" - "scripts/verify-requirements.sh" diff --git a/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-PLAN.md b/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-PLAN.md new file mode 100644 index 0000000..693d77c --- /dev/null +++ b/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-PLAN.md @@ -0,0 +1,58 @@ +--- +status: implementing +issue: 37 +depends_on: 46 +--- + +# Signed openSUSE repository development + +Goal: complete every development task in #37 and open a dedicated PR containing `resolves #37`. This branch is an +intentional stack on #36/PR #46 because the openSUSE channel reuses the exact-release provenance, RPM signing, +content-addressed metadata retention, protected publication, and KVM proof foundations introduced there. Production +provider ownership, credentials, signing identity, GitHub environment configuration, and first public activation +remain the separately listed human operational tasks. + +## Decisions + +- Choose a project-owned repository over OBS. It publishes the exact authenticated project release RPM, preserves the + release/source/build identity already recorded by Loopwire, and gives the project explicit promotion, retention, + rollback, and recovery semantics. OBS would rebuild and sign a distinct output, requiring provider project/build + identities and provider-specific proof that cannot be produced without the human operational setup. +- Initial scope is openSUSE Tumbleweed x86_64 only. Leap and other architectures remain unsupported until they receive + their own package and clean-guest validation. +- Reuse the shared RPM repository protocol only where libzypp/Zypper behavior proves it compatible. Keep the target, + client bootstrap, workflow, public activation record, docs, and guest proof openSUSE-specific. +- Require authenticated repository metadata and embedded RPM signatures. The bootstrap path must preserve Zypper + checks and may not use `--no-gpg-checks` or `--allow-unsigned-rpm`. +- Retain immutable signed RPMs and content-addressed metadata indefinitely in v1. Publication must serialize writers, + require revision CAS, reject immutable collisions, recover interruption, and expose only complete revisions or a + verification failure during promotion. +- The checked-in openSUSE channel remains pending. Existing signed direct-download and automatic-installer paths stay + visible until a maintainer completes and reviews the production public proof record. + +## Work lanes + +1. Repository protocol: extend exact-release authentication, RPM/repository signing, metadata manifests, rollback, and + tamper tests for the openSUSE Tumbleweed target, grounded in real Zypper/libzypp behavior. +2. Publication: extend local/SSH publication, atomic promotion, CAS/locks/recovery, immutable retention, and public + HTTP proof for the openSUSE namespace. +3. Guest proof: run an isolated clean Tumbleweed KVM lifecycle using the actual public v0.1.0 openSUSE RPM, plus a + strictly synthetic upgrade, and verify raw provenance, installed bytes, providers, backend JSON, and GUI linkage. +4. Product/docs: add the pending/verified homepage gate, tested bootstrap, provider decision, rolling-snapshot policy, + support matrix, release/operator guidance, navigation, and unreleased notes. +5. Root integration: protected workflow, config contracts, focused and full gates, browser fixtures, final review, + issue checklist update, and PR delivery. + +## Required verification + +- Real Zypper accepts correct signed metadata and package content and rejects wrong, unsigned, or tampered content. + Version, architecture, target, vendor/origin, downgrade, and repository removal behavior are independently verified. +- Publication proves writer exclusion, CAS, ordering, idempotence, interruption recovery, permissions, immutable + retention, rollback, actual SSH transport, and public cache behavior without production credentials. +- A clean checksum-pinned Tumbleweed KVM guest performs repository install, reinstall, fixture upgrade, explicit + rollback/downgrade, removal, and repository removal against final committed development code. Evidence records the + snapshot and binds source/distributed hashes, signer, origin/vendor, versions, installed bytes, providers, and GUI. +- Documentation defines a repeatable later-snapshot compatibility run and requires disabling activation/escalating a + failed snapshot until the package or compatibility declaration is repaired and the full lifecycle reruns. +- Pending and verified-fixture browser states, workflow/action syntax, docs, 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-nrs-signed-opensuse/260905-nrs-SUMMARY.md b/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-SUMMARY.md new file mode 100644 index 0000000..3a5ca2e --- /dev/null +++ b/.planning/quick/260905-nrs-signed-opensuse/260905-nrs-SUMMARY.md @@ -0,0 +1,88 @@ +--- +status: complete +issue: 37 +depends_on: 46 +--- + +# Signed openSUSE repository development + +Issue: https://github.com/sandwichfarm/loopwire/issues/37 + +## Result and stack + +The openSUSE Tumbleweed x86_64 development work is complete. This branch intentionally stacks on #36/PR #46 so the +channel can reuse the reviewed exact-release provenance, RPM signing, SSH/POSIX publication, protected environment, +and KVM foundations while retaining an independent target, namespace, state, workflow, bootstrap, activation record, +documentation, and guest proof. The checked-in channel remains `pending`; the five Human operational tasks still own +production hosting, signing custody, GitHub configuration, first public publication, and activation. + +## Development checklist evidence + +1. **Provider evaluation:** the maintainer runbook compares OBS and project-owned delivery across output identity, + signing, promotion/recovery, and rolling compatibility. Project-owned delivery was selected because it consumes the + exact authenticated GitHub Release RPM and supports the existing independently testable publication protocol. OBS + would produce a provider build requiring provisioned project/build identities and separate provider proof. +2. **Package path:** the generator accepts only the Tumbleweed x86_64 `loopwire-VERSION-1.x86_64.rpm`, authenticates + the OpenSSL-signed SHA256SUMS plus release-assets manifest, and binds the exact RPM and x86_64 archive to the stable + tag and public release commit. The public source RPM is never mutated; only a staged copy is repository-signed. +3. **Signing:** an isolated OpenPGP identity signs the staged RPM and `repodata/repomd.xml`. The manifest and signed + metadata retain separate source/distributed hashes and the public source revision. Zypper is configured with + `gpgcheck=1`, `repo_gpgcheck=1`, and `pkg_gpgcheck=1`; wrong, unsigned, and tampered inputs fail closed. +4. **Publication/rollback:** openSUSE uses `/opensuse/tumbleweed/x86_64` publicly and isolated private locks, CAS, + journals, and snapshots under `channels/opensuse-tumbleweed-x86_64`. Immutable objects are retained indefinitely in + v1. Signature-first/atomic-metadata promotion, interruption recovery, retry idempotence, and freshly signed rollback + were tested with real Zypper, actual SSH, and live Nginx cache behavior. +5. **Protected automation:** Publish openSUSE Repository uses a checksum-pinned Tumbleweed toolchain and supports stable + release publication, weekly refresh, and retained-revision rollback. `OPENSUSE_REPOSITORY_ENABLED=true` plus the + `packages-production` environment gate writes. The job waits for existing release gates, verifies public release + inputs, publishes over pinned SSH, verifies served HTTPS bytes, and emits a reviewable activation record. +6. **Bootstrap:** the repeat-safe helper accepts Tumbleweed x86_64 only, downloads the fingerprint-addressed public key + over HTTPS, verifies one complete primary fingerprint before writes, and atomically installs only its managed key + and `.repo` file. Dry-run has no network or writes. Removal preserves packages, accepted RPM-database trust, and + unrelated repositories. Docs cover the interactive key prompt, rotation, vendor protection, priority, and cleanup. +7. **Regression tests:** real Zypper accepts the signed repository and rejects wrong/malformed keys, missing/changed + signatures, changed metadata, and unsigned/tampered/wrong-key RPMs. Generator and publisher tests cover target, + NEVRA/version/architecture, release provenance, downgrade/repack rejection, deterministic retention, encrypted + keys, unsafe paths, locks, CAS, every interruption point, permissions, SSH transport, HTTP caching, and rollback. +8. **Tumbleweed lifecycle/compatibility:** a checksum-pinned clean Tumbleweed `20260829` KVM guest used the actual + public v0.1.0 openSUSE RPM and authenticated release manifest. It installed and reinstalled the baseline, upgraded + only to the declared synthetic `+zypperfixture1`, rolled back to the public baseline, removed the package, isolated + trust database, and repository. Evidence binds source/distributed hashes, release commit, signature, transaction + origin, active candidate, native installed view, vendor, `/usr` bytes, providers, backend JSON, GUI linkage, and a + real X11 window at every installed stage. Later snapshot runs consume the same explicit target manifest during run + and verification; a failure blocks activation until repaired or compatibility is narrowed and the lifecycle reruns. +9. **Docs/UI:** homepage, install guide, support matrix, release guide, user/operator guides, packaging docs, navigation, + and unreleased notes are updated. Pending preserves the authenticated direct-RPM/automatic installer fallback; + verified-fixture proof switches only openSUSE to `sudo zypper install loopwire` with separate setup guidance. The + repository is identified as third-party and never described as default-distribution or publicly active. + +## Verification + +- Final pre-guest `pnpm check` passed requirements/docs, automation, workflow contracts, runtime/install/package gates, + types, 295 workspace tests, 22 Rust tests, production builds, static-site checks, and APT/Fedora/openSUSE suites. +- `pnpm verify:opensuse-repository` passed in the pinned Tumbleweed image: 7 generator, 27 publisher, 7 bootstrap, + 6 public HTTPS, 3 workflow preflight, workflow contract, 39 raw proof-verifier, and 5 channel-gate cases. The publisher + exercised actual SSH and real Zypper failure/recovery around mixed metadata. +- Fedora regression verification passed: 13 generator and 27 publisher cases plus bootstrap, public, workflow, proof, + and UI gates, including real DNF behavior. +- The clean KVM lifecycle at `08a18e4484627e36233ab068891bc468e9613f84` produced 159 evidence files and passed an + independent replay. The guest is snapshot `20260829`; its image SHA-256 is + `e80d2d1f9cfb328c79a5031d6a30f9744ec83824f6ba44c41ce831ff829a5dad`. Public baseline source SHA-256 is + `f6bc10589e6308fdc405faa104835cd6bcc486c4bc3fba95b5808b94267f1d05`, bound to public release commit + `dfdecf30d681c553a906ceebb13c859bb1b46ef3`. +- Pending and verified-fixture Chromium suites passed all platform, command, keyboard/copy, no-JavaScript, responsive, + motion/reduced-motion/visibility, openSUSE guide, and setup-link assertions. Correct selected-tab screenshots passed + visual verdict at 96/100; the checked-in channel and final build were restored to pending. +- Actionlint, ShellCheck, Python/Node/Ruby/Bash syntax, docs/workflow contracts, and whitespace passed. Comprehensive + review findings for read-only syntax, snapshot-manifest propagation, and workflow path coverage were fixed and the + re-review approved the result. + +## Boundaries + +The issue's five Human operational tasks remain unchecked. No production account, OBS project, host, TLS/DNS, +OpenPGP identity, SSH credential, GitHub environment value, repository publication, or website activation was created +or changed. Fixture keys, CA, SSH server, synthetic upgrade, and isolated RPM trust database are disposable. + +Zypper authenticates repository metadata and RPMs but does not enforce Loopwire's custom signed verification deadline; +HTTPS/origin control, weekly refresh, and monitoring remain operational requirements. Power-loss and network +filesystems were not exercised; production requires local POSIX lock/fsync/atomic-rename behavior. diff --git a/apps/docs/docs/.vitepress/config.ts b/apps/docs/docs/.vitepress/config.ts index 4ca04f9..c042e8a 100644 --- a/apps/docs/docs/.vitepress/config.ts +++ b/apps/docs/docs/.vitepress/config.ts @@ -45,6 +45,7 @@ export default defineConfig({ { text: "Install", link: "/guide/install" }, { text: "APT Repository", link: "/guide/apt-repository" }, { text: "Fedora Repository", link: "/guide/fedora-repository" }, + { text: "openSUSE Repository", link: "/guide/opensuse-repository" }, { text: "Basic Usage", link: "/guide/basic-usage" }, { text: "Configurations", link: "/guide/configurations" }, { text: "Audio Backends", link: "/guide/backends" }, @@ -64,6 +65,7 @@ export default defineConfig({ { text: "Release", link: "/developer/release" }, { text: "APT Repository Operations", link: "/developer/apt-repository" }, { text: "Fedora Repository Operations", link: "/developer/fedora-repository" }, + { text: "openSUSE Repository Operations", link: "/developer/opensuse-repository" }, { text: "Release Notes", link: "/developer/release-notes" } ] }, diff --git a/apps/docs/docs/developer/opensuse-repository.md b/apps/docs/docs/developer/opensuse-repository.md new file mode 100644 index 0000000..8298992 --- /dev/null +++ b/apps/docs/docs/developer/opensuse-repository.md @@ -0,0 +1,228 @@ +# Signed openSUSE repository operations + +This runbook is for maintainers publishing and recovering Loopwire's third-party Tumbleweed channel. Development +tooling and isolated guest evidence do not establish production availability. Provisioning the origin, signing key, +protected environment, first public publication, and reviewed activation remain human operations. The checked-in +channel stays `pending` until that public proof exists; signed direct downloads remain available. + +## Scope and provider decision + +The only initial target is **openSUSE Tumbleweed x86_64**. Its canonical repository URL ends in +`/opensuse/tumbleweed/x86_64`. The command-line target selector is `opensuse-tumbleweed-x86_64`; the public channel +record uses `target: "opensuse-tumbleweed"`. Leap and ARM64 need separate package and clean-guest validation. + +The project selected project-owned hosting after comparing it with Open Build Service (OBS): + +OBS separates build, signing, and publishing services; an integration would need to track that provider workflow and +its output identity. See the [official OBS architecture](https://openbuildservice.org/help/manuals/obs-user-guide/cha-obs-architecture). + +| Requirement | OBS | Project-owned repository | +| --- | --- | --- | +| Release identity | A provider build would need its own project, build, and exact-output provenance. | Authenticate the existing GitHub Release RPM and sign a staged copy; retain source and distributed hashes. | +| Signing and ownership | Provider project and signing operations require provisioned ownership and their own trust procedure. | A dedicated OpenPGP identity signs RPMs and metadata under the protected project environment. | +| Promotion and recovery | Requires a provider-specific publication/proof implementation. | Reuse verified SSH/POSIX publication, revision checks, immutable retention, and recovery journals. | +| Rolling compatibility | Provider build success alone would not prove the installed Loopwire lifecycle. | Require the recorded Tumbleweed snapshot and complete clean-guest install/upgrade/rollback proof. | + +This is a project decision about the implemented proof and release flow, not a claim that OBS cannot host Loopwire. +No OBS project is provisioned or advertised by this change. Project-owned hosting preserves the authenticated release +payload and known publication contract at the cost of operating HTTPS, SSH access, signing recovery, monitoring, and +storage. Revisit OBS only with a separate provider integration and equivalent exact-output/client evidence. + +## Trust, layout, and provisioning + +The generator first verifies `SHA256SUMS.sig` using the existing OpenSSL release key, then the exact openSUSE RPM's +checksum and target identity. It signs only a staged copy with the repository OpenPGP key; the GitHub Release input +stays unchanged. The manifest records both hashes because adding an embedded RPM signature changes the distributed +bytes. Zypper must verify both signed `repodata/repomd.xml` and the RPM's embedded signature. + +The public tree contains fingerprint-named keys, immutable RPMs, checksum-named metadata, `repomd.xml`, its detached +signature, and the repository manifest. Provision an HTTPS origin with a valid public certificate, Python 3, and +restricted SSH access. Keep staging and public files on one POSIX filesystem. Serve **`ROOT/public` only**. For this +target, public objects live below `ROOT/public/opensuse/tumbleweed/x86_64`, while private state and snapshots live below +`ROOT/channels/opensuse-tumbleweed-x86_64`. Fedora retains its separate namespace and state. + +Do not expose locks, journals, snapshots, private keys, or SSH credentials to HTTP. Back up private state and snapshots. +Existing directory permissions must allow the HTTP service to traverse the public tree and its ancestors; the +publisher preserves pre-provisioned directory modes. The existing website upload surface does not establish the +required atomic rename and revision-check contract, so package publication uses SSH/POSIX hosting separately. + +Mutable `repomd.xml`, its signature, the current manifest, and missing-object responses require `no-store` or mandatory +revalidation. RPMs, fingerprint-named public keys, and checksum-named metadata can have a one-year immutable cache +policy. Never cache a missing mutable file through a publication. Use the repository Nginx example as the starting +point and verify actual public headers. + +Create a dedicated OpenPGP identity offline. Record the complete uppercase 40-character fingerprint, expiry, +custodian, backup, and revocation-certificate location. Keep recovery material outside CI and never send the private +key to the origin. Configure the protected GitHub environment **`packages-production`**: + +| Kind | Name | Purpose | +| --- | --- | --- | +| Repository variable | `OPENSUSE_REPOSITORY_ENABLED` | Enables protected publication only after provisioning. | +| Variable | `OPENSUSE_REPOSITORY_URL` | Canonical HTTPS target URL ending in `/opensuse/tumbleweed/x86_64`. | +| Variable | `OPENSUSE_REPOSITORY_HOST` | Restricted SSH `USER@HOST`. | +| Variable | `OPENSUSE_REPOSITORY_ROOT` | Absolute private origin root, whose `public` child is served. | +| Variable | `OPENSUSE_SIGNING_FINGERPRINT` | Complete uppercase OpenPGP fingerprint. | +| Optional variable | `OPENSUSE_SSH_PORT` | SSH port, default 22. | +| Secret | `OPENSUSE_SSH_PRIVATE_KEY` | Restricted publishing identity. | +| Secret | `OPENSUSE_SSH_KNOWN_HOSTS` | Host-key pins obtained through a trusted channel. | +| Secret | `OPENSUSE_SIGNING_KEY` | ASCII-armored private signing export. | +| Optional secret | `OPENSUSE_SIGNING_PASSPHRASE` | Passphrase for that export. | + +Restrict environment branches/tags to reviewed inputs and apply the environment's human approval policy. Keep the +enable variable false until every input is ready. Enabling writes does not activate the website. Monitor publication +and refresh failures, TLS/signing expiry, project verification deadlines, origin drift, and storage growth. + +## Build, verify, and publish + +Use a reviewed checkout and authenticated published stable release files. Set `RPM_FPR` to the full OpenPGP +fingerprint and `RPM_GNUPG_HOME` to its protected local GnuPG home. These are operator staging commands: + +The input directory must contain the openSUSE RPM, `loopwire-linux-x86_64.tar.gz`, `release-assets.json`, `SHA256SUMS`, +and `SHA256SUMS.sig`. The signed asset manifest binds the release tag, full source commit, archive, and exact native +RPM hashes. Any other release assets present must also match that authenticated inventory. A checksum-only RPM +directory is insufficient for this target's source/build provenance checks. + +```bash +python3 scripts/rpm-repository.py build --target opensuse-tumbleweed-x86_64 \ + --release-dir dist/release --version 0.1.0 --output dist/opensuse-repository \ + --signing-key "$RPM_FPR" --gnupg-home "$RPM_GNUPG_HOME" \ + --release-public-key packaging/release-signing-public.pem +python3 scripts/rpm-repository.py verify --target opensuse-tumbleweed-x86_64 \ + --repository dist/opensuse-repository --public-key rpm-public.asc --fingerprint "$RPM_FPR" +``` + +Local tools include Python 3, RPM/rpmsign, `createrepo_c`, GnuPG, and OpenSSL. Actual Zypper acceptance is a separate +matching-distribution test. `--previous DIR` retains older objects/packages. `--passphrase-file FILE` supplies a private +passphrase file. `--date EPOCH` controls reproducible fixtures; production uses current time. A 30-day signed project +verification deadline is enforced by Loopwire's verifier and publisher. Zypper does not enforce that custom tag, and +`autorefresh=1` is not an anti-replay guarantee. Refresh metadata before the deadline and monitor the origin. + +Use **Publish openSUSE Repository** for protected production publication, refresh, and rollback. Tagged release +integration runs only after GitHub Release publication and when `OPENSUSE_REPOSITORY_ENABLED=true`. An openSUSE failure +does not retract the existing GitHub Release or mutate the Fedora/APT channel. Repair and retry the target job. + +The publisher's `fetch`, `publish`, and `recover` operations require the explicit openSUSE selector. In these examples, +`RPM_ROOT` and `RPM_HOST` are the provisioned origin root/host, and `RPM_REVISION` is the verified current revision: + +```bash +python3 scripts/publish-rpm-repository.py fetch --target opensuse-tumbleweed-x86_64 \ + --root "$RPM_ROOT" --output dist/opensuse-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 --target opensuse-tumbleweed-x86_64 \ + --repository dist/opensuse-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 +``` + +Initial publication skips fetch and uses an empty expected revision. For later publication, build using the fetched +snapshot as `--previous`, review the dry-run output, and repeat without `--dry-run`. Add `--ssh-port PORT` if needed. +A lock serializes this target's writers; compare-and-swap rejects a stale expected revision. Refetch and rebuild after +a conflict instead of overriding it. Immutable collisions are rejected. + +Immutable objects are installed first. Promotion writes the new detached metadata signature and atomically replaces +`repomd.xml`. The pair is not a single filesystem operation: during the bounded signature/metadata mismatch a client +must fail verification and retry, never treat incomplete state as successful installation. Existing complete package +objects remain available. Require successful refresh, correct source/candidate selection, and a real authenticated +package install when checking Zypper behavior. + +## Recovery, retention, and rollback + +An interrupted promotion retains an exact recovery journal. Inspect and resume it with the same target, key, and SSH +arguments using `recover --dry-run`, then repeat without `--dry-run`. Fetch is blocked while recovery is pending. +For a journal whose project deadline has passed, `recover --allow-expired` is an explicit operator exception that +finishes only that authentic transition and requires immediate fetch, fresh signing, and publication. Ordinary +publication never accepts an expired candidate. Fetch can authenticate expired snapshots at their signed creation +time for recovery; that does not prove current client usability. + +Version 1 retains immutable RPMs, key files, checksum-named metadata, and private snapshots indefinitely. There is no +automatic garbage collection. Any deletion policy needs a separate design for cached clients, manifest references, +rollback availability, and incident evidence. Do not edit generated state to bypass retention or revision checks. + +Fetch a known-good snapshot with `fetch --revision SHA` and re-sign its package set with fresh metadata: + +```bash +python3 scripts/rpm-repository.py rollback --target opensuse-tumbleweed-x86_64 \ + --repository dist/opensuse-known-good --output dist/opensuse-rollback \ + --signing-key "$RPM_FPR" --gnupg-home "$RPM_GNUPG_HOME" +``` + +Verify and publish against the current revision, then repeat public verification. Existing newer installations need +an explicit client downgrade: `sudo zypper install --oldpackage --no-allow-vendor-change --no-allow-arch-change --from loopwire 'loopwire=VERSION-RELEASE'`. +The [user guide](../guide/opensuse-repository.md#earlier-versions) covers exact-version selection and dependency errors. + +## Key rotation and revocation + +Generate a successor key offline and announce its full fingerprint through trusted project channels. The conservative +rotation path uses a fresh origin root/HTTPS prefix and authenticated release inputs signed by the successor. Test +clean setup, existing-client migration, install/reinstall/upgrade/downgrade, and removal before production. Clients +rerun the inspected bootstrap with the reviewed new URL and key; package updates do not silently grant a new signer +trust. Keep the old prefix during the announced migration period only while its key remains trustworthy. + +Fingerprint-named public key objects are immutable. Extending expiry or changing subkeys changes bytes and cannot +overwrite the same URL; use the successor-key procedure. Old snapshots still require their original trust anchor. +The bootstrap removes only its managed files; keys accepted by RPM/libzypp require separate exact-identity review. + +For compromise, disable publication/refresh, remove the private CI export, publish the revocation and incident notice, +and return the public channel record to pending. Clients must remove the old source and bootstrap an independently +verified replacement; a revocation certificate alone does not repair cached keyrings. Preserve incident evidence and +recover only from authenticated release inputs or independently known-good snapshots. + +## Rolling snapshot policy + +Record Tumbleweed's `/etc/os-release` `VERSION_ID`, the checksum-pinned guest image identity, package version, Zypper +and RPM versions, signer, repository origin/vendor, and source/distributed hashes in each proof. One passing snapshot +does not imply future rolling compatibility. Before widening a compatibility claim, repeat the complete lifecycle on +the intended newer snapshot using the same authenticated release inputs and a fresh isolated VM. + +Use a reviewed target-manifest override through `LOOPWIRE_NATIVE_VM_TARGETS` for the newer official image and its +verified SHA-256. Use a distinct `LOOPWIRE_OPENSUSE_VM_ROOT` so old evidence is preserved. Run both `run-opensuse-repo` +and `verify-opensuse-repo` against the new snapshot; retain the manifest and logs with the evidence. + +**A failed newer-snapshot run blocks activation.** If a public channel is already advertised, set its record to pending, +deploy the fallback instructions, and pause publication while investigating. File the exact failure and snapshot, +repair the package or explicitly narrow the compatibility declaration, then rerun the full clean-guest lifecycle. +Do not reclassify a failed mandatory check as a skip or reuse an older passing screenshot. Escalate unresolved +dependency, GUI, provider, signing, or vendor behavior before restoring an availability claim. + +## Public verification and final activation + +After publication, compare every production HTTPS byte against the signed candidate: + +```bash +python3 scripts/verify-opensuse-public.py \ + --repository dist/opensuse-repository --public-key rpm-public.asc --fingerprint "$RPM_FPR" \ + --base-url "$OPENSUSE_URL" --proof-url "$OPENSUSE_PROOF_URL" --output dist/opensuse-channel.json +``` + +The checker must validate the local signed chain, HTTPS responses without redirects, exact hashes/sizes, and target +identity. Fixture CAs, synthetic upgrades, and local SSH rehearsals prove development behavior only. + +The workflow artifact `loopwire-opensuse-publication-RUN_ID` contains the publication report, repository manifest, and +`opensuse-channel.json`. **Final activation is a human operation:** review that successful protected run and the +initial production clean-client lifecycle, including the actual snapshot, URL, signer, RPM provenance, and versions. +Copy the emitted record to `packaging/repositories/opensuse-channel.json` in a reviewed commit and deploy the website. +Do not set `status` alone or paste fixture proof into production configuration. + +A verified schema-version-1 record requires the openSUSE target, HTTPS `baseUrl`, full uppercase fingerprint, lowercase +64-character `revision`, ISO `verifiedAt`, and project GitHub Actions `proofUrl`. Snapshot and package-hash evidence +live in the repository/VM proof manifests. Missing or invalid fields keep the existing signed direct-download command. +Only a complete reviewed record changes the openSUSE tab to `sudo zypper install loopwire` with a separate setup link. + +## Development verification + +Run shared RPM protocol/publication/public-proof tests, the openSUSE bootstrap/workflow checks, channel tests, docs, +and site/browser gates. Real Zypper tests must reject unsigned/changed metadata and RPMs, wrong signers, and incomplete +promotion. The clean KVM lifecycle uses: + +```bash +bash scripts/native-package-vm.sh run-opensuse-repo \ + --target opensuse-tumbleweed --version 0.1.0 --release-dir dist/release +bash scripts/native-package-vm.sh verify-opensuse-repo --target opensuse-tumbleweed +node --test apps/site/src/lib/opensuseChannel.test.mjs +``` + +Require setup, install, reinstall, synthetic upgrade, explicit rollback/downgrade, package removal, and source removal, +with candidate/origin/vendor and signature evidence plus the installed GUI and provider checks. Synthetic revisions +reuse authenticated release payloads; they are not newly published application releases or public availability proof. +This lifecycle does not promote openSUSE desktop-session or live audio-backend support. diff --git a/apps/docs/docs/developer/release.md b/apps/docs/docs/developer/release.md index eec7a4f..c8ada51 100644 --- a/apps/docs/docs/developer/release.md +++ b/apps/docs/docs/developer/release.md @@ -35,6 +35,21 @@ The Fedora homepage tab remains on its signed direct-download RPM while 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. +## Signed openSUSE channel + +Tumbleweed x86_64 publication has a separate [openSUSE operations runbook](./opensuse-repository.md). It selects a +project-owned repository over OBS to preserve authenticated release-input provenance and the existing signing, +retention, revision-check, and recovery contract. It requires both embedded RPM and repository-metadata signatures. + +The optional **Publish openSUSE Repository** workflow runs after GitHub Release publication only when +`OPENSUSE_REPOSITORY_ENABLED=true`, using `packages-production`. Production hosting, signing/environment setup, first +public verification, and reviewed channel activation remain human operations. Until then the openSUSE homepage keeps +the authenticated direct-RPM fallback. Fedora and APT have independent gates. + +Record the Tumbleweed snapshot in lifecycle evidence. A later-snapshot failure blocks activation and must be resolved +through a package repair or explicit compatibility correction followed by a full clean-guest lifecycle rerun. Do not +promote synthetic upgrades or older snapshot evidence into a new public compatibility claim. + ## Local Artifact Smoke ```bash diff --git a/apps/docs/docs/guide/install.md b/apps/docs/docs/guide/install.md index 15e5805..7a726d3 100644 --- a/apps/docs/docs/guide/install.md +++ b/apps/docs/docs/guide/install.md @@ -172,8 +172,17 @@ sha256sum --check --ignore-missing SHA256SUMS && sudo zypper install --allow-unsigned-rpm ./loopwire-0.1.0-1.x86_64.rpm ``` -[Repository work to shorten this setup](https://github.com/sandwichfarm/loopwire/issues/37) tracks signed metadata, -release publication, clean-guest install/upgrade verification, and updated instructions. +[openSUSE repository setup and availability](./opensuse-repository.md) covers the one-time key/source setup, normal +updates, vendor and priority behavior, rollback, removal, and Tumbleweed snapshot compatibility. Once that page shows +a verified public channel, complete setup and refresh before using: + +```bash +sudo zypper install loopwire +``` + +This is a third-party project repository for Tumbleweed x86_64, not default-distro availability. Until activation, +use Automatic or the authenticated direct download above. Its local-RPM signature exception never applies to the +repository path, which requires both signed metadata and embedded RPM signatures. ## Nix / NixOS @@ -286,9 +295,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 and -Fedora repositories have separate [APT](./apt-repository.md) and [Fedora](./fedora-repository.md) public activation -gates; the openSUSE repository remains planned. +The AppImages and native deb/RPM files are published as direct downloads on the `v0.1.0` GitHub Release. The project +repositories have separate [APT](./apt-repository.md), [Fedora](./fedora-repository.md), and +[openSUSE](./opensuse-repository.md) public activation gates. 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/opensuse-repository.md b/apps/docs/docs/guide/opensuse-repository.md new file mode 100644 index 0000000..fd100e6 --- /dev/null +++ b/apps/docs/docs/guide/opensuse-repository.md @@ -0,0 +1,154 @@ + + +# openSUSE repository + +Loopwire's project-owned, third-party repository targets **openSUSE Tumbleweed on x86_64**. It requires one-time +setup; Loopwire is not supplied by the default openSUSE repositories through this channel. This is not an OBS project +or an openSUSE-maintained package. Leap and other architectures need their own validated packages; 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 openSUSE direct download or the automatic + installer. The setup command appears here only after production verification has been reviewed.

+
+ +
+

Verified public channel

+

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

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

Run it with the verified URL and complete OpenPGP fingerprint:

+
{{ setupCommand }}
+
+ +The helper accepts only Tumbleweed x86_64. It downloads the key over HTTPS and checks the complete fingerprint +**before** configuring the source or refreshing metadata. It writes `/etc/zypp/repos.d/loopwire.repo` and +`/etc/zypp/keys/loopwire-repository-FINGERPRINT.asc`. Repeating setup with the same inputs is safe; an unrelated +definition using that filename is an error. Other sources are preserved. Add `--dry-run` and omit `sudo` to preview +the target and managed paths without downloads or changes. + +The repository alias is `loopwire`, its type is `rpm-md`, and `autorefresh=1` enables normal metadata refresh. +`gpgcheck=1`, `repo_gpgcheck=1`, and `pkg_gpgcheck=1` require signed metadata and signed RPMs. Priority stays at +`99`; setup does not change global solver settings or automatically switch package vendors. +The [libzypp configuration reference](https://manpages.opensuse.org/Tumbleweed/libzypp/zypp.conf.5.en.html) explains +these separate signature settings. + +After successful setup, refresh the repository, compare any key prompt's full fingerprint with this page, and install: + +```bash +sudo zypper refresh loopwire && +sudo zypper install loopwire +``` + +The helper does not run either command for you or automatically accept a Zypper key prompt. Reject a mismatched +fingerprint. Confirm the candidate with `zypper info --repo loopwire loopwire` when needed. It must come from the +configured URL and match the expected package version and x86_64 architecture. Installation includes the desktop and +background/provider commands without enabling startup services or applying audio routes. + +## Updates, reinstall, and vendor selection + +To update only Loopwire after setup: + +```bash +sudo zypper refresh loopwire && +sudo zypper update loopwire +``` + +For normal distribution-wide Tumbleweed updates, follow openSUSE's system-upgrade procedure. Do not run a +distribution-wide vendor switch to install or update this one application. + +Inspect installed identity and available versions before repairing or changing the package: + +```bash +rpm -q --qf '%{NAME} %{VERSION}-%{RELEASE} %{ARCH} vendor=%{VENDOR}\n' loopwire +zypper search --details --repo loopwire --match-exact loopwire +``` + +To reinstall the same retained version, replace `VERSION-RELEASE` with the installed value: + +```bash +sudo zypper install --force --no-allow-vendor-change --no-allow-arch-change \ + --from loopwire 'loopwire=VERSION-RELEASE' +``` + +Saved Loopwire configurations are outside package ownership and are preserved. The RPM vendor is a header field +carried from the authenticated release; it is separate from both repository origin and signing fingerprint. If an +existing package came from another vendor, review that change explicitly. Only when intentionally migrating that +package, use `sudo zypper install --allow-vendor-change --from loopwire loopwire`. Keep the normal global vendor +protection enabled. [libzypp vendor protection](https://opensuse.github.io/libzypp/pg_zypp-solv-vendorchange.html) +explains why switching repository URLs does not itself change a package's vendor. + +## Earlier versions + +Publishing an older recommended package set does not downgrade an installed newer version. After maintainers +recommend a rollback, choose the exact version listed by the search command above: + +```bash +sudo zypper refresh loopwire && +sudo zypper install --oldpackage --no-allow-vendor-change --no-allow-arch-change \ + --from loopwire 'loopwire=VERSION-RELEASE' +``` + +This permits a downgrade while preserving signature, dependency, vendor, and architecture checks. Do not substitute +a Fedora or Leap RPM. If dependency resolution fails on your Tumbleweed snapshot, stop and report it instead +of ignoring dependencies. See the [official Zypper reference](https://manpages.opensuse.org/Tumbleweed/zypper/zypper.8.en.html) +for exact-version installation and `--oldpackage` behavior. + +## Remove the package or repository + +Use `sudo zypper remove loopwire` to remove package-owned files while keeping saved configuration. Remove any startup +integration you enabled separately, using the [start-on-boot guide](./start-on-boot.md). + +To stop using the repository, run the same inspected helper: + +```bash +sudo bash setup-opensuse-repository.sh --remove +``` + +It removes the managed `.repo` file and its referenced Loopwire public-key file. It does not uninstall Loopwire, +alter other repositories, or erase keys already accepted into the RPM database or libzypp key cache. Inspect +`zypper repos --details` to confirm that alias `loopwire` is absent. Keys in shared databases need separate careful +cleanup by exact identity if required; do not remove a shared distro key or match only a short key ID. + +## Tumbleweed compatibility and trust + +Tumbleweed is rolling. A passing repository test proves the recorded snapshot and package set, not every future +snapshot. If a later snapshot fails installation, launch, provider checks, or the package lifecycle, maintainers must +withhold activation, publish the limitation, and repair the package or compatibility statement before rerunning the +complete clean-guest lifecycle. Report the `VERSION_ID` from `/etc/os-release`, exact package version, candidate +repository, and error output. [The compatibility policy](../developer/opensuse-repository.md#rolling-snapshot-policy) +describes that gate. + +The OpenPGP repository key authenticates the distributed RPM and metadata. It is separate from the OpenSSL key that +authenticates direct GitHub Release checksums. The local-RPM `--allow-unsigned-rpm` exception belongs only to the +authenticated direct-download fallback; never add it or `--no-gpg-checks` to repository commands. + +For a routine key change, verify the successor's full fingerprint through trusted project announcements before +rerunning setup with the new URL and fingerprint. For a compromised or revoked key, remove the repository immediately +and follow the incident notice. Publishing a revocation certificate does not update every existing client's key cache. + +Zypper validates signatures, but a valid signature alone cannot distinguish replayed older metadata. Loopwire's +publication checks enforce a signed project verification deadline; client autorefresh does not enforce that custom +deadline. Report unexpected rollback, metadata-signature failures, checksum mismatches, or missing objects and retry +only after the repository is repaired. Do not disable TLS, signature, or dependency checks to continue. diff --git a/apps/docs/docs/guide/support-matrix.md b/apps/docs/docs/guide/support-matrix.md index 01c6397..bb5b65d 100644 --- a/apps/docs/docs/guide/support-matrix.md +++ b/apps/docs/docs/guide/support-matrix.md @@ -74,7 +74,8 @@ the Tauri shell command bridge. | 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 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. | +| openSUSE Tumbleweed RPM | Verified in a matching KVM guest at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as a direct download. | +| openSUSE Tumbleweed signed project repository, x86_64 | Signed-RPM/metadata, publication, bootstrap, and snapshot-bound clean-guest lifecycle checks | Public activation is gated; see [current channel availability](./opensuse-repository.md). No OBS project is advertised. | | 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. | @@ -94,6 +95,12 @@ publication recovery, and install/reinstall/upgrade/downgrade/removal behavior o 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. +openSUSE repository proof is scoped to the recorded Tumbleweed snapshot on x86_64. It covers the signed repository +and installed package lifecycle, including explicit downgrade and source removal, separately from desktop/audio +support. Leap, ARM64, and later rolling snapshots are not implied. A failed newer-snapshot run blocks activation until +the package or compatibility statement is repaired and the full lifecycle passes again; see the +[rolling snapshot policy](../developer/opensuse-repository.md#rolling-snapshot-policy). + 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 767f5e9..ef79c69 100644 --- a/apps/docs/docs/release-notes/unreleased.md +++ b/apps/docs/docs/release-notes/unreleased.md @@ -30,6 +30,20 @@ These notes describe source-tree progress. They are not a public release announc - Added [Fedora user setup and rollback guidance](../guide/fedora-repository.md) and the [maintainer operations runbook](../developer/fedora-repository.md). +## Signed openSUSE repository development + +- Selected a project-owned repository over OBS to retain authenticated release-input identity, signing, atomic + metadata promotion, indefinite retention, and recovery under the existing release workflow. +- Added the Tumbleweed x86_64 target with authenticated metadata and embedded RPM signatures, protected publication, + repeat-safe setup/removal, and a dedicated clean-guest lifecycle. Failed later-snapshot checks block activation + until compatibility is repaired and the lifecycle reruns. +- The openSUSE homepage switches to `sudo zypper install loopwire` only with a complete reviewed public verification + record. Production provisioning and initial activation remain human work; direct downloads and Automatic stay + functional while the channel is pending. +- Added [openSUSE user setup and recovery](../guide/opensuse-repository.md) and the + [maintainer runbook](../developer/opensuse-repository.md), including priority/vendor behavior, key changes, downgrade, + and rolling-snapshot policy. + ## Other distribution updates - Root-level Bunny deployments now pass the post-upload live-site check when `BUNNY_REMOTE_PREFIX` is intentionally diff --git a/apps/site/src/lib/opensuseChannel.test.mjs b/apps/site/src/lib/opensuseChannel.test.mjs new file mode 100644 index 0000000..cb4b69b --- /dev/null +++ b/apps/site/src/lib/opensuseChannel.test.mjs @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { fedoraInstallOption, opensuseInstallOption, verifiedFedoraChannel, verifiedOpenSuseChannel } from "./rpmChannel.mjs"; + +const verified = { + schemaVersion: 1, status: "verified", target: "opensuse-tumbleweed", + baseUrl: "https://packages.example.test/opensuse/tumbleweed/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: "opensuse", command: "sudo zypper install --allow-unsigned-rpm ./loopwire-0.1.0-1.x86_64.rpm", + note: "Authenticate the signed release checksum before installing this local RPM.", + detail: "Use the matching portable path on other targets.", + href: "/docs/guide/opensuse-repository.html", link: "openSUSE repository setup and availability" +}; + +test("pending or incomplete openSUSE records retain the authenticated direct-RPM fallback", () => { + for (const record of [null, undefined, {}, [], { ...verified, status: "pending" }]) { + assert.equal(verifiedOpenSuseChannel(record), null); + assert.equal(opensuseInstallOption(record, manual), manual); + } + for (const key of Object.keys(verified)) { + const record = { ...verified }; + delete record[key]; + assert.equal(verifiedOpenSuseChannel(record), null, `missing ${key}`); + assert.equal(opensuseInstallOption(record, manual), manual); + } +}); + +test("verified openSUSE fixture exposes normal Zypper install and a separate setup link", () => { + assert.equal(verifiedOpenSuseChannel(verified).baseUrl, "https://packages.example.test/opensuse/tumbleweed/x86_64"); + const option = opensuseInstallOption(verified, manual); + assert.equal(option.id, "opensuse"); + assert.equal(option.command, "sudo zypper install loopwire"); + assert.equal(option.href, "/docs/guide/opensuse-repository.html#one-time-setup"); + assert.doesNotMatch(`${option.note} ${option.detail}`, /workflow|revision|activation|operator/); + assert.match(manual.command, /--allow-unsigned-rpm/); +}); + +test("Fedora and openSUSE verification records cannot activate each other's install option", () => { + const fedora = { ...verified, target: "fedora-44" }; + assert.ok(verifiedFedoraChannel(fedora)); + assert.equal(verifiedFedoraChannel(verified), null); + assert.equal(verifiedOpenSuseChannel(fedora), null); + assert.equal(fedoraInstallOption(verified, manual), manual); + assert.equal(opensuseInstallOption(fedora, manual), manual); +}); + +test("malformed or unsupported openSUSE proof records never activate repository instructions", () => { + const invalid = { + schemaVersion: [0, "1"], status: [true, "ready"], + target: ["opensuse-leap", "opensuse-tumbleweed-aarch64", "opensuse-tumbleweed-x86_64", null], + baseUrl: ["http://packages.example.test", "https://user:pass@packages.example.test", "not a URL", + "https://packages.example.test?", "https://packages.example.test#", "https://packages.example.test?q=1", + "https://packages.example.test:0", "https://packages.example.test:65536", " https://packages.example.test", + "https://packages.example.test/\n", "https://packages.example.test/$(id)", "https://packages.example.test/'"], + 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/37", + "https://github.com/sandwichfarm/loopwire/actions/runs/123?fixture=1"] + }; + for (const [key, values] of Object.entries(invalid)) { + for (const value of values) { + const record = { ...verified, [key]: value }; + assert.equal(verifiedOpenSuseChannel(record), null, `${key}: ${value}`); + assert.equal(opensuseInstallOption(record, manual), manual); + } + } +}); + +test("checked-in openSUSE channel remains pending or has a complete verification record", () => { + const channel = JSON.parse(readFileSync(new URL("../../../../packaging/repositories/opensuse-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(verifiedOpenSuseChannel(channel)); + } +}); diff --git a/apps/site/src/lib/rpmChannel.mjs b/apps/site/src/lib/rpmChannel.mjs index 1d8bb88..df9e83b 100644 --- a/apps/site/src/lib/rpmChannel.mjs +++ b/apps/site/src/lib/rpmChannel.mjs @@ -1,13 +1,23 @@ /** - * Fedora repository instructions are advertised only after the complete public + * RPM 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) { + return verifiedRpmChannel(value, "fedora-44"); +} + +/** @param {unknown} value */ +export function verifiedOpenSuseChannel(value) { + return verifiedRpmChannel(value, "opensuse-tumbleweed"); +} + +/** @param {unknown} value @param {string} target */ +function verifiedRpmChannel(value, target) { if (!value || typeof value !== "object") return null; const channel = /** @type {Record} */ (value); - if (channel.schemaVersion !== 1 || channel.status !== "verified" || channel.target !== "fedora-44" || + if (channel.schemaVersion !== 1 || channel.status !== "verified" || channel.target !== target || 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) || @@ -63,3 +73,21 @@ export function fedoraInstallOption(channel, manual) { link: "Set up the Fedora repository" }; } + +/** + * @template {{command: string, note: string, detail: string, href: string, link: string}} T + * @param {unknown} channel + * @param {T} manual + * @returns {T} + */ +export function opensuseInstallOption(channel, manual) { + if (!verifiedOpenSuseChannel(channel)) return manual; + return { + ...manual, + command: "sudo zypper install loopwire", + note: "After one-time setup, install and update Loopwire through its signed openSUSE repository.", + detail: "For openSUSE Tumbleweed on x86_64. Check the guide for rolling-release compatibility.", + href: "/docs/guide/opensuse-repository.html#one-time-setup", + link: "Set up the openSUSE repository" + }; +} diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 38acfd9..1d545bc 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -3,8 +3,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 opensuseChannel from "../../../../packaging/repositories/opensuse-channel.json"; import { aptInstallOption } from "../lib/aptChannel.mjs"; -import { fedoraInstallOption } from "../lib/rpmChannel.mjs"; +import { fedoraInstallOption, opensuseInstallOption } from "../lib/rpmChannel.mjs"; const title = "Loopwire | Linux virtual audio routing"; const description = @@ -72,15 +73,15 @@ const installOptions = [ "handles verification for you. The signed DNF repository is pending public verification.", href: "/docs/guide/fedora-repository.html", link: "Fedora repository setup and availability" }), - { + opensuseInstallOption(opensuseChannel, { 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"), note: "Manual signed v0.1.0 download. Run these steps together in an empty folder. The signed checksum " + "authenticates the RPM before Zypper installs it.", detail: "The RPM has no embedded signature; the exception applies to this verified file. Automatic handles " + - "verification for you. A signed repository is planned.", - href: "https://github.com/sandwichfarm/loopwire/issues/37", link: "Track simpler openSUSE installs" - }, + "verification for you.", + href: "/docs/guide/opensuse-repository.html", link: "openSUSE repository setup and availability" + }), { id: "nix", label: "Nix / NixOS", heading: "Nix · user profile", command: 'nix --extra-experimental-features "nix-command flakes" profile install github:sandwichfarm/loopwire#loopwire-bin', diff --git a/package.json b/package.json index 08efc2c..17b3572 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 && pnpm verify:rpm-repository", + "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 && pnpm verify:opensuse-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", @@ -68,6 +68,7 @@ "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:opensuse-repository": "bash scripts/verify-opensuse-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 ce0d11a..c4f8c1e 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -190,6 +190,42 @@ fingerprint from the [gated Fedora repository guide](../apps/docs/docs/guide/fed 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. +## Signed openSUSE repository + +The project-owned openSUSE channel targets Tumbleweed x86_64 at `/opensuse/tumbleweed/x86_64/`. It authenticates the +existing release RPM with the project's OpenSSL-signed checksum manifest, signs a staged copy with a dedicated +OpenPGP repository key, and publishes authenticated RPM metadata. The original GitHub Release file stays unchanged; +the manifest records source and distributed hashes. This approach was selected over OBS to preserve the current +release identity and publication/recovery proof. It does not provision or advertise an OBS project. + +The input directory needs the signed release checksum manifest plus `release-assets.json`, the x86_64 portable +archive, and the exact openSUSE RPM. Their authenticated inventory binds the release tag, full source commit, and +source/build hashes; a lone RPM and checksum entry do not satisfy the openSUSE provenance contract. + +Use the explicit command-line target for the shared repository generator and publisher: + +```bash +python3 scripts/rpm-repository.py build --target opensuse-tumbleweed-x86_64 \ + --release-dir dist/release --version 0.1.0 --output dist/opensuse-repository \ + --signing-key "$RPM_FPR" --gnupg-home "$RPM_GNUPG_HOME" \ + --release-public-key packaging/release-signing-public.pem +python3 scripts/rpm-repository.py verify --target opensuse-tumbleweed-x86_64 \ + --repository dist/opensuse-repository --public-key rpm-public.asc --fingerprint "$RPM_FPR" +``` + +The publisher keeps this target's private state under `ROOT/channels/opensuse-tumbleweed-x86_64` and its served +objects under `ROOT/public/opensuse/tumbleweed/x86_64`. The protected workflow uses `OPENSUSE_REPOSITORY_ENABLED` and +the `packages-production` environment. Origin, signing key, credentials, public proof, and first activation remain +human operations. The checked-in `packaging/repositories/opensuse-channel.json` stays pending until its reviewed +production proof record exists. + +The helper writes only the managed Zypper source and fingerprint-named key, verifies the complete fingerprint before +refresh, and preserves `gpgcheck=1`, `repo_gpgcheck=1`, `pkg_gpgcheck=1`, priority 99, and normal vendor protection. +User instructions come from the [gated openSUSE guide](../apps/docs/docs/guide/opensuse-repository.md). The +[operator runbook](../apps/docs/docs/developer/opensuse-repository.md) covers publishing, rollback, retained snapshots, +rotation, removal, and the required newer-snapshot compatibility rerun. A failed newer Tumbleweed snapshot blocks +activation; one passing snapshot does not establish support for all future rolling updates. + ## Native deb and RPM packages The native package recipes install the complete canonical payload: GUI, background restore, DSP and JACK provider diff --git a/packaging/repositories/Dockerfile.opensuse-rpm-tools b/packaging/repositories/Dockerfile.opensuse-rpm-tools new file mode 100644 index 0000000..c678ee9 --- /dev/null +++ b/packaging/repositories/Dockerfile.opensuse-rpm-tools @@ -0,0 +1,9 @@ +FROM opensuse/tumbleweed@sha256:b6821dbfad5422b663e0eeae8241a30ef2976e1e9ae4a2f827641c0eecf7e4fd + +RUN zypper --non-interactive refresh \ + && zypper --non-interactive install --no-recommends \ + cpio createrepo_c gh git gpg2 nginx nodejs24 openssh openssl python3 rpm-build ruby ShellCheck zypper \ + && zypper clean --all + +ENV PYTHONDONTWRITEBYTECODE=1 +WORKDIR /workspace diff --git a/packaging/repositories/nginx-rpm.conf b/packaging/repositories/nginx-rpm.conf index e12e8fe..f14965b 100644 --- a/packaging/repositories/nginx-rpm.conf +++ b/packaging/repositories/nginx-rpm.conf @@ -1,6 +1,7 @@ # 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/. +# private. Base URLs are https://HOST/fedora/44/x86_64/ and +# https://HOST/opensuse/tumbleweed/x86_64/. root /srv/loopwire-rpm/public; autoindex off; disable_symlinks on; @@ -19,6 +20,12 @@ location ~ "^/fedora/44/x86_64/(packages/loopwire-[A-Za-z0-9][A-Za-z0-9.+_~]*-1\ add_header Cache-Control "public, max-age=31536000, immutable"; } +location ~ "^/opensuse/tumbleweed/x86_64/(packages/loopwire-[A-Za-z0-9][A-Za-z0-9.+_~]*-1\.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; @@ -26,10 +33,17 @@ location @rpm_missing { # 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. +# request sequence that crosses publication fails closed under DNF or libzypp +# metadata-signature verification, 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; } + +location /opensuse/tumbleweed/x86_64/ { + try_files $uri =404; + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + etag off; +} diff --git a/packaging/repositories/opensuse-channel.json b/packaging/repositories/opensuse-channel.json new file mode 100644 index 0000000..5aad5fd --- /dev/null +++ b/packaging/repositories/opensuse-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/vm/guest-opensuse-repository-smoke.sh b/packaging/vm/guest-opensuse-repository-smoke.sh new file mode 100755 index 0000000..4c01bb5 --- /dev/null +++ b/packaging/vm/guest-opensuse-repository-smoke.sh @@ -0,0 +1,392 @@ +#!/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" = opensuse-tumbleweed ] || { echo "unsupported openSUSE repository guest target" >&2; exit 2; } +[ "$package_target" = opensuse-tumbleweed ] && [ "$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/opensuse-repository-fixture" +release_dir="$kit_dir/release" +public_release_proof="$proof_dir/public-release" +base_url="https://127.0.0.1:8445/opensuse/tumbleweed/x86_64" +upgrade_version="${version}+zypperfixture1" +[[ "$version" != *+* ]] || upgrade_version="${version}.zypperfixture1" +baseline_package_version="${version}-1" +upgrade_package_version="${upgrade_version}-1" +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 before using its openSUSE RPM 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 name in entries, 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) +opensuse = [item for item in manifest["artifacts"] if isinstance(item, dict) and item.get("target") == "opensuse-tumbleweed"] +assert len(opensuse) == 1 and set(opensuse[0]) == {"name", "kind", "target", "architecture", "bytes", "sha256"} +rpm = opensuse[0] +assert (rpm["name"], rpm["kind"], rpm["target"], rpm["architecture"]) == (rpm_name, "native-rpm", "opensuse-tumbleweed", "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" +for release_file in "$baseline_package" loopwire-linux-x86_64.tar.gz SHA256SUMS SHA256SUMS.sig release-assets.json; do + cp "$release_dir/$release_file" "$public_release_proof/" +done +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 zypper --non-interactive refresh +sudo zypper --non-interactive install --no-recommends \ + ca-certificates ca-certificates-mozilla cpio createrepo_c curl findutils gpg2 gzip nodejs openssl \ + python3 rpm-build tar xdotool xorg-x11-server-Xvfb + +# Private fixture keys stay inside the disposable guest and are never copied into proof. +gnupg_home="$fixture_dir/gnupg" +mkdir -m 0700 "$gnupg_home" +gpg --homedir "$gnupg_home" --batch --pinentry-mode loopback --passphrase '' \ + --quick-generate-key 'Loopwire disposable openSUSE 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" + +upgrade_release="$fixture_dir/upgrade-release" +mkdir -p "$upgrade_release" +SOURCE_DATE_EPOCH=0 bash scripts/build-rpm-package.sh --target opensuse-tumbleweed \ + --version "$upgrade_version" --arch x86_64 --release-dir "$release_dir" --output-dir "$upgrade_release" +[ -f "$upgrade_release/$upgrade_package" ] +[ "$(find "$upgrade_release" -maxdepth 1 -type f -name '*.rpm' | wc -l)" -eq 1 ] +upgrade_source_sha256="$(sha256sum "$upgrade_release/$upgrade_package" | awk '{ print $1 }')" +cp "$release_dir/loopwire-linux-x86_64.tar.gz" "$upgrade_release/" +python3 - "$upgrade_release" "$upgrade_version" "$upgrade_package" "$public_release_git_head" <<'PY' +import hashlib, json, pathlib, sys +root, version, rpm_name, revision = pathlib.Path(sys.argv[1]), sys.argv[2], sys.argv[3], sys.argv[4] +def artifact(name, kind, target): + data = (root / name).read_bytes() + return {"name": name, "kind": kind, "target": target, "architecture": "x86_64", + "bytes": len(data), "sha256": hashlib.sha256(data).hexdigest()} +manifest = {"schema": "loopwire.release-assets.v1", + "release": {"tag": f"v{version}", "version": version, "gitHead": revision}, + "artifacts": [artifact(rpm_name, "native-rpm", "opensuse-tumbleweed"), + artifact("loopwire-linux-x86_64.tar.gz", "portable-archive", "linux-generic")]} +(root / "release-assets.json").write_text(json.dumps(manifest, indent=2) + "\n") +PY +( + cd "$upgrade_release" + sha256sum "$upgrade_package" loopwire-linux-x86_64.tar.gz release-assets.json >SHA256SUMS +) +openssl dgst -sha256 -sign "$fixture_dir/synthetic-release-key.pem" \ + -out "$upgrade_release/SHA256SUMS.sig" "$upgrade_release/SHA256SUMS" +baseline_source_sha256="$(sha256sum "$release_dir/$baseline_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 --target opensuse-tumbleweed-x86_64 \ + --release-dir "$release_dir" --version "$version" --output "$fixture_dir/initial" \ + --signing-key "$fingerprint" --gnupg-home "$gnupg_home" +python3 scripts/rpm-repository.py build --target opensuse-tumbleweed-x86_64 \ + --release-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 --target opensuse-tumbleweed-x86_64 \ + --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 --target opensuse-tumbleweed-x86_64 \ + --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" + +fixture_rpmdb="/var/lib/loopwire-repository-proof-rpmdb" +sudo test ! -e "$fixture_rpmdb" +sudo install -d -m 0700 -o root -g root "$fixture_rpmdb" +# Tumbleweed's enforcing SELinux policy permits RPM database writes only under +# the system database label. The database remains isolated and is removed below. +sudo chcon --reference=/usr/lib/sysimage/rpm "$fixture_rpmdb" +sudo rpm --dbpath "$fixture_rpmdb" --initdb +sudo rpm --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 HTTPS trust without introducing a production credential. +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/trust/anchors/loopwire-guest-fixture.crt +sudo update-ca-certificates +mkdir -p "$fixture_dir/www/opensuse/tumbleweed" +ln -s "$fixture_dir/initial" "$fixture_dir/www/opensuse/tumbleweed/x86_64" +cat >"$fixture_dir/https-server.py" <<'PY' +import functools, http.server, ssl, sys +class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + 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", 8445), 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=$! +cleanup() { kill "$server_pid" 2>/dev/null || true; } +trap cleanup EXIT +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-opensuse-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-opensuse-repository.sh --base-url "$base_url" --fingerprint "$fingerprint" \ + >"$proof_dir/bootstrap.log" 2>&1 +cat /etc/zypp/repos.d/loopwire.repo >"$proof_dir/loopwire.repo" +cat "/etc/zypp/keys/loopwire-repository-$fingerprint.asc" >"$proof_dir/configured-repository-key.asc" +cmp "$proof_dir/repository-key.asc" "$proof_dir/configured-repository-key.asc" +sudo zypper --non-interactive --gpg-auto-import-keys refresh --force loopwire >"$proof_dir/bootstrap-refresh.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}\t%{VENDOR}\n' loopwire >"$stage_dir/package-metadata.tsv" + [ "$(rpm -q --qf '%{VERSION}-%{RELEASE}' loopwire)" = "$expected_version" ] + [ "$(rpm -q --qf '%{VENDOR}' loopwire)" = '(none)' ] + zypper --no-refresh --xmlout search --installed-only --details --match-exact loopwire >"$stage_dir/zypper-search.xml" + zypper --no-refresh --xmlout search --details --repo loopwire --match-exact loopwire \ + >"$stage_dir/zypper-repository-search.xml" + python3 - "$stage_dir/zypper-search.xml" "$stage_dir/zypper-repository-search.xml" \ + "$proof_dir/$stage.log" "$expected_version" >"$stage_dir/zypper-origin.tsv" <<'PY' +import pathlib, sys, xml.etree.ElementTree as ET +installed_path, repository_path, log_path, version = sys.argv[1:] +root = ET.parse(installed_path).getroot() +items = [item for item in root.iter("solvable") if item.attrib.get("name") == "loopwire"] +assert len(items) == 1 +item = items[0] +assert item.attrib.get("status") == "installed" and item.attrib.get("edition") == version +repository = "Loopwire for openSUSE Tumbleweed - x86_64" +assert item.attrib.get("arch") == "x86_64" +assert item.attrib.get("repository") in (repository, "(System Packages)") +candidates = [item for item in ET.parse(repository_path).getroot().iter("solvable") + if item.attrib.get("name") == "loopwire" and item.attrib.get("edition") == version] +assert len(candidates) == 1 and candidates[0].attrib.get("arch") == "x86_64" +assert candidates[0].attrib.get("repository") == repository +log = pathlib.Path(log_path).read_text() +assert f"Retrieving: loopwire-{version}.x86_64 ({repository})" in log +print("loopwire\t%s\tx86_64\t%s\t(none)" % (version, repository)) +PY + zypper --no-refresh info loopwire >"$stage_dir/zypper-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" + sudo 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 'Loopwire GUI has an unresolved shared-library dependency' >&2 + exit 1 + fi + local gui_status=0 + # 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 'Loopwire GUI logged a fatal launch error' >&2 + exit 1 + fi + printf '%s\t%s\tinstalled\n' "$stage" "$expected_version" >>"$proof_dir/lifecycle.tsv" +} + +sudo zypper --non-interactive install --from loopwire --no-recommends "loopwire=$baseline_package_version" >"$proof_dir/install.log" 2>&1 +smoke_installed install "$baseline_package_version" "$baseline_package" +sudo zypper --non-interactive install --from loopwire --force --no-allow-vendor-change --no-allow-arch-change \ + --no-recommends "loopwire=$baseline_package_version" >"$proof_dir/reinstall.log" 2>&1 +smoke_installed reinstall "$baseline_package_version" "$baseline_package" +ln -sfn "$fixture_dir/upgraded" "$fixture_dir/www/opensuse/tumbleweed/x86_64" +verify_public_stage upgraded +sudo zypper --non-interactive --gpg-auto-import-keys refresh --force loopwire >"$proof_dir/upgrade-refresh.log" 2>&1 +sudo zypper --non-interactive update --repo loopwire loopwire >"$proof_dir/upgrade.log" 2>&1 +smoke_installed upgrade "$upgrade_package_version" "$upgrade_package" +ln -sfn "$fixture_dir/rolled-back" "$fixture_dir/www/opensuse/tumbleweed/x86_64" +verify_public_stage rolled-back +sudo zypper --non-interactive --gpg-auto-import-keys refresh --force loopwire >"$proof_dir/rollback-refresh.log" 2>&1 +sudo zypper --non-interactive install --from loopwire --oldpackage --force \ + --no-allow-vendor-change --no-allow-arch-change --no-recommends \ + "loopwire=$baseline_package_version" >"$proof_dir/rollback.log" 2>&1 +smoke_installed rollback "$baseline_package_version" "$baseline_package" +sudo zypper --non-interactive remove loopwire >"$proof_dir/remove.log" 2>&1 +if rpm -q loopwire >/dev/null 2>&1; then + echo 'Loopwire package remains installed 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 rm -rf -- "$fixture_rpmdb" +test ! -e "$fixture_rpmdb" +sudo bash scripts/setup-opensuse-repository.sh --remove >"$proof_dir/source-removal.log" 2>&1 +test ! -e /etc/zypp/repos.d/loopwire.repo +test ! -e "/etc/zypp/keys/loopwire-repository-$fingerprint.asc" +sudo zypper clean --all >"$proof_dir/source-removal-clean.log" 2>&1 +zypper repos --details >"$proof_dir/source-removal-repositories.txt" +if grep -Eq '(^|[[:space:]|])loopwire([[:space:]|]|$)' "$proof_dir/source-removal-repositories.txt"; then + echo 'Loopwire repository remains configured after removal' >&2 + exit 1 +fi + +snapshot="$(sed -n 's/^VERSION_ID="\{0,1\}\([^"[:space:]]*\)"\{0,1\}$/\1/p' /etc/os-release)" +[[ "$snapshot" =~ ^[0-9]{8}$ ]] +{ + printf 'schema\tloopwire.opensuse-repository-vm-proof.v1\n' + printf 'target\t%s\n' "$target" + printf 'snapshot\t%s\n' "$snapshot" + 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 "openSUSE repository lifecycle proof passed: $target snapshot $snapshot" diff --git a/scripts/e2e-site-install.mjs b/scripts/e2e-site-install.mjs index d1b827a..5d17d96 100644 --- a/scripts/e2e-site-install.mjs +++ b/scripts/e2e-site-install.mjs @@ -6,11 +6,13 @@ import { mkdirSync, readFileSync } from "node:fs"; import { createServer } from "node:http"; import { createRequire } from "node:module"; import { extname, resolve, sep } from "node:path"; +import { verifiedOpenSuseChannel } from "../apps/site/src/lib/rpmChannel.mjs"; const root = resolve("dist/site"); const guide = readFileSync("apps/docs/docs/guide/install.md", "utf8"); const platforms = ["automatic", "arch", "ubuntu", "debian", "fedora", "opensuse", "nix", "portable", "source"]; const automatic = "curl -fsSL https://loopwire.app/install.sh | bash"; +const opensuseChannel = verifiedOpenSuseChannel(JSON.parse(readFileSync("packaging/repositories/opensuse-channel.json", "utf8"))); const proofIndex = process.argv.indexOf("--screenshots"); const proofDir = proofIndex < 0 ? undefined : process.argv[proofIndex + 1]; assert.ok(proofIndex < 0 || proofDir, "--screenshots requires an output directory"); @@ -26,8 +28,9 @@ const server = createServer((request, response) => { const path = decodeURIComponent(new URL(request.url, "http://localhost").pathname); const file = resolve(root, `.${path.endsWith("/") ? `${path}index.html` : path}`); if (!file.startsWith(`${root}${sep}`)) { response.writeHead(403).end(); return; } + const content = readFileSync(file); response.writeHead(200, { "content-type": mime[extname(file)] ?? "application/octet-stream" }); - response.end(readFileSync(file)); + response.end(content); } catch { response.writeHead(404).end(); } }); await new Promise((done) => server.listen(0, "127.0.0.1", done)); @@ -77,6 +80,35 @@ try { assert.equal(paletteColors.size, platforms.length, "every tab selects a distinct background palette"); console.log("PASS: all nine panels, shell command syntax, guide parity and selected-command clipboard"); + const opensuse = await context.newPage(); + opensuse.on("pageerror", (error) => errors.push(error.message)); + await opensuse.goto(url); + await opensuse.locator("#install-tab-opensuse").click(); + const opensuseCommand = await opensuse.locator("#install-panel-opensuse code").textContent(); + const opensuseLink = opensuse.locator("#install-panel-opensuse .install-notes a"); + if (opensuseChannel) { + assert.equal(opensuseCommand, "sudo zypper install loopwire"); + assert.equal(await opensuseLink.getAttribute("href"), "/docs/guide/opensuse-repository.html#one-time-setup"); + } else { + assert.match(opensuseCommand, /openssl dgst -sha256 -verify/); + assert.match(opensuseCommand, /sudo zypper install --allow-unsigned-rpm \.\/loopwire-0\.1\.0-1\.x86_64\.rpm/); + } + await opensuse.goto(`${url}/docs/guide/opensuse-repository.html`); + const setupCommands = (await opensuse.locator("pre code").allTextContents()) + .filter((command) => command.startsWith("sudo bash setup-opensuse-repository.sh --base-url")); + if (opensuseChannel) { + assert.equal(setupCommands.length, 1); + assert.equal(setupCommands[0], `sudo bash setup-opensuse-repository.sh --base-url '${opensuseChannel.baseUrl}' --fingerprint ${opensuseChannel.signingFingerprint}`); + execFileSync("bash", ["-n"], { input: setupCommands[0] }); + assert.equal(await opensuse.getByText("Public channel pending", { exact: true }).count(), 0); + assert.ok(await opensuse.getByText("Verified public channel", { exact: true }).isVisible()); + } else { + assert.deepEqual(setupCommands, []); + assert.ok(await opensuse.getByText("Public channel pending", { exact: true }).isVisible()); + } + await opensuse.close(); + console.log(`PASS: openSUSE ${opensuseChannel ? "verified" : "pending"} homepage and separate bootstrap guide`); + await page.locator("#install-tab-source").focus(); for (const [key, platform] of [["ArrowRight", "automatic"], ["ArrowLeft", "source"], ["Home", "automatic"], ["End", "source"]]) { await page.keyboard.press(key); @@ -223,6 +255,9 @@ try { await proof.locator('#signal-field[data-motion="interactive"]').waitFor(); await proof.evaluate(() => document.fonts.ready); await proof.screenshot({ path: resolve(proofDir, "desktop.png"), fullPage: true }); + await proof.locator("#install-tab-opensuse").click(); + await proof.waitForTimeout(1000); + await proof.screenshot({ path: resolve(proofDir, "opensuse-desktop.png"), fullPage: true }); await proof.locator("#install-tab-ubuntu").click(); await proof.waitForTimeout(1000); await proof.screenshot({ path: resolve(proofDir, "ubuntu-palette.png"), fullPage: true }); @@ -237,6 +272,11 @@ try { await mobile.goto(url); await mobile.locator('#signal-field[data-motion="interactive"]').waitFor(); await mobile.evaluate(() => document.fonts.ready); + await mobile.locator("#install-tab-opensuse").click(); + await mobile.waitForTimeout(1000); + await mobile.screenshot({ path: resolve(proofDir, "opensuse-mobile.png"), fullPage: true }); + await mobile.locator("#install-tab-automatic").click(); + await mobile.waitForTimeout(1000); await mobile.screenshot({ path: resolve(proofDir, "mobile.png"), fullPage: true }); console.log(`Screenshot proofs written to ${proofDir}`); } diff --git a/scripts/native-package-vm.sh b/scripts/native-package-vm.sh index e4ac9cf..e15c756 100755 --- a/scripts/native-package-vm.sh +++ b/scripts/native-package-vm.sh @@ -27,11 +27,14 @@ Usage: 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] + native-package-vm.sh run-opensuse-repo --target opensuse-tumbleweed --version VERSION --release-dir DIR + native-package-vm.sh verify-opensuse-repo --target opensuse-tumbleweed [--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_OPENSUSE_VM_ROOT openSUSE repository run/evidence root (default: .vm/opensuse-repository) LOOPWIRE_NATIVE_VM_TARGETS Target manifest override LOOPWIRE_QEMU_IMAGE Docker QEMU tool image tag @@ -262,6 +265,13 @@ run_target() { release-assets.json; do [ -f "$release_dir/$release_file" ] || fail "Fedora repository release artifact is missing: $release_file" done + elif [ "$proof_kind" = "opensuse-repository" ]; then + for release_file in \ + "loopwire-${version}-1.x86_64.rpm" \ + SHA256SUMS.sig \ + release-assets.json; do + [ -f "$release_dir/$release_file" ] || fail "openSUSE repository release artifact is missing: $release_file" + done fi require_host require_committed_implementation @@ -285,6 +295,9 @@ run_target() { elif [ "$proof_kind" = "fedora-repository" ]; then container="loopwire-fedora-repository-$id" port=$((port + 20)) + elif [ "$proof_kind" = "opensuse-repository" ]; then + container="loopwire-opensuse-repository-$id" + port=$((port + 30)) fi key="$(ensure_ssh_key)" public_key="$(cat "${key}.pub")" @@ -301,6 +314,10 @@ run_target() { 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/" + elif [ "$proof_kind" = "opensuse-repository" ]; then + cp "$release_dir/loopwire-${version}-1.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" @@ -343,6 +360,9 @@ run_target() { elif [ "$proof_kind" = "fedora-repository" ]; then guest_script="packaging/vm/guest-fedora-repository-smoke.sh" verifier="scripts/verify-fedora-repository-vm-proof.mjs" + elif [ "$proof_kind" = "opensuse-repository" ]; then + guest_script="packaging/vm/guest-opensuse-repository-smoke.sh" + verifier="scripts/verify-opensuse-repository-vm-proof.mjs" fi local guest_status=0 # Script paths are fixed and all client-expanded arguments are validated above. @@ -364,8 +384,11 @@ run_target() { actual_checksum "$(checksum_file "$algorithm" "$image_path")" \ firmware "$firmware" >"$evidence_dir/image.tsv" [ "$guest_status" -eq 0 ] || fail "$id guest smoke failed ($guest_status); evidence: $evidence_dir" - node "$verifier" \ - --target "$id" --evidence-dir "$evidence_dir" --git-head "$git_head" + local -a verifier_args=(--target "$id" --evidence-dir "$evidence_dir" --git-head "$git_head") + if [ "$proof_kind" = "opensuse-repository" ]; then + verifier_args+=(--target-manifest "$manifest") + fi + node "$verifier" "${verifier_args[@]}" cleanup_active_vm active_vm_container="" active_vm_console="" @@ -373,6 +396,7 @@ run_target() { local proof_label="native package" [ "$proof_kind" != "apt" ] || proof_label="APT repository lifecycle" [ "$proof_kind" != "fedora-repository" ] || proof_label="Fedora repository lifecycle" + [ "$proof_kind" != "opensuse-repository" ] || proof_label="openSUSE repository lifecycle" echo "Verified $proof_label in matching KVM guest: $id" echo "Evidence: $evidence_dir" } @@ -384,8 +408,13 @@ verify_target() { 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" + [ "$proof_kind" != "opensuse-repository" ] || verifier="scripts/verify-opensuse-repository-vm-proof.mjs" + local -a verifier_args=(--target "$selected" --evidence-dir "$vm_root/evidence/$selected/$git_head" + --git-head "$git_head") + if [ "$proof_kind" = "opensuse-repository" ]; then + verifier_args+=(--target-manifest "$manifest") + fi + node "$verifier" "${verifier_args[@]}" } [ -n "$root" ] || fail "run from inside the Loopwire git repository" @@ -429,6 +458,13 @@ case "$command" in [ "$(realpath -m "$vm_root")" != "$(realpath -m "$image_root")" ] || fail "Fedora repository VM state must use a different root from native-package state" ;; + run-opensuse-repo | verify-opensuse-repo) + [ "$selected" = "opensuse-tumbleweed" ] || fail "$command requires the openSUSE Tumbleweed target" + proof_kind="opensuse-repository" + vm_root="${LOOPWIRE_OPENSUSE_VM_ROOT:-.vm/opensuse-repository}" + [ "$(realpath -m "$vm_root")" != "$(realpath -m "$image_root")" ] || + fail "openSUSE repository VM state must use a different root from native-package state" + ;; esac case "$command" in @@ -447,7 +483,7 @@ case "$command" in require_host while read -r id; do download_target "$id"; done < <(target_ids) ;; - run | run-apt | run-fedora-repo) + run | run-apt | run-fedora-repo | run-opensuse-repo) [ -n "$selected" ] || fail "run requires --target" [ -n "$version" ] || fail "run requires --version" [ -n "$release_dir" ] || fail "run requires --release-dir" @@ -458,7 +494,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-fedora-repo) + verify | verify-apt | verify-fedora-repo | verify-opensuse-repo) [ -n "$selected" ] || fail "verify requires --target" verify_target "$selected" "$git_head" ;; diff --git a/scripts/publish-opensuse-workflow.sh b/scripts/publish-opensuse-workflow.sh new file mode 100755 index 0000000..33b9964 --- /dev/null +++ b/scripts/publish-opensuse-workflow.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +set -euo pipefail + +target="opensuse-tumbleweed-x86_64" +fail() { printf 'publish-opensuse-workflow: %s\n' "$*" >&2; exit 1; } +for name in OPENSUSE_REPOSITORY_URL OPENSUSE_REPOSITORY_HOST OPENSUSE_REPOSITORY_ROOT OPENSUSE_SIGNING_FINGERPRINT \ + OPENSUSE_SSH_PRIVATE_KEY OPENSUSE_SSH_KNOWN_HOSTS OPENSUSE_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 +[[ "$OPENSUSE_SIGNING_FINGERPRINT" =~ ^[A-F0-9]{40}$ ]] || fail "signing fingerprint must be complete uppercase hexadecimal" +[[ "${OPENSUSE_SSH_PORT:-22}" =~ ^[0-9]+$ ]] || fail "SSH port must be numeric" +python3 - "$OPENSUSE_REPOSITORY_URL" <<'PY' +import runpy, sys +validate = runpy.run_path('scripts/verify-opensuse-public.py')['validate_base_url'] +try: + validate(sys.argv[1]) +except ValueError as error: + sys.exit(f'publish-opensuse-workflow: {error}') +PY + +work="$(mktemp -d "$RUNNER_TEMP/loopwire-opensuse.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' "$OPENSUSE_SSH_PRIVATE_KEY" >"$work/ssh-key" +printf '%s\n' "$OPENSUSE_SSH_KNOWN_HOSTS" >"$work/known-hosts" +printf '%s\n' "$OPENSUSE_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 "$OPENSUSE_SIGNING_FINGERPRINT" >"$work/public-key.asc" +[ -s "$work/public-key.asc" ] || fail "configured key does not match the expected fingerprint" +sign_args=(--signing-key "$OPENSUSE_SIGNING_FINGERPRINT" --gnupg-home "$work/gnupg" --valid-for-days 30) +if [ -n "${OPENSUSE_SIGNING_PASSPHRASE:-}" ]; then + printf '%s' "$OPENSUSE_SIGNING_PASSPHRASE" >"$work/passphrase" + sign_args+=(--passphrase-file "$work/passphrase") +fi +unset OPENSUSE_SSH_PRIVATE_KEY OPENSUSE_SSH_KNOWN_HOSTS OPENSUSE_SIGNING_KEY OPENSUSE_SIGNING_PASSPHRASE +transport=(--target "$target" --root "$OPENSUSE_REPOSITORY_ROOT" --ssh "$OPENSUSE_REPOSITORY_HOST" + --ssh-port "${OPENSUSE_SSH_PORT:-22}" --identity-file "$work/ssh-key" --known-hosts "$work/known-hosts" + --public-key "$work/public-key.asc" --fingerprint "$OPENSUSE_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 openSUSE 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('openSUSE 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 --target "$target" --release-dir "$work/release" \ + --version "${RELEASE_TAG#v}" --output "$work/candidate" "${sign_args[@]}" "${previous_args[@]}" + ;; + refresh) + python3 scripts/rpm-repository.py rollback --target "$target" --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 --target "$target" --repository "$work/rollback" \ + --output "$work/candidate" "${sign_args[@]}" + ;; +esac + +mkdir -p dist/opensuse-publication +python3 scripts/publish-rpm-repository.py publish "${transport[@]}" --repository "$work/candidate" \ + --expected-revision "$expected" >dist/opensuse-publication/publication.json +python3 scripts/verify-opensuse-public.py --repository "$work/candidate" --public-key "$work/public-key.asc" \ + --fingerprint "$OPENSUSE_SIGNING_FINGERPRINT" --base-url "$OPENSUSE_REPOSITORY_URL" \ + --proof-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + --output dist/opensuse-publication/opensuse-channel.json +cp "$work/candidate/repository-manifest.json" dist/opensuse-publication/repository-manifest.json +printf 'openSUSE 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 index f727b1e..d9f1220 100644 --- a/scripts/publish-rpm-repository.py +++ b/scripts/publish-rpm-repository.py @@ -1,9 +1,12 @@ #!/usr/bin/env python3 -"""Publish a verified Fedora RPM repository to a POSIX origin. +"""Publish verified Fedora and openSUSE RPM repositories 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. +ROOT/public is the HTTP document root. Supported base URLs are +ROOT/public/fedora/44/x86_64 and ROOT/public/opensuse/tumbleweed/x86_64. +Fedora keeps its existing private ROOT/state and ROOT/snapshots paths. openSUSE +uses ROOT/channels/opensuse-tumbleweed-x86_64/{state,snapshots} so locks, journals, +revisions, and rollback inputs remain independent. Package, key, and +checksum-named repodata URLs are immutable and retained within each namespace. 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 @@ -39,15 +42,43 @@ MANIFEST = "repository-manifest.json" SCHEMA = "loopwire.rpm-repository.v1" -TARGET = {"distribution": "fedora", "release": "44", "architecture": "x86_64"} -PUBLIC_PREFIX = Path("fedora/44/x86_64") +DEFAULT_TARGET = "fedora-44-x86_64" +TARGETS = { + DEFAULT_TARGET: { + "manifest": {"distribution": "fedora", "release": "44", "architecture": "x86_64"}, + "publicPrefix": Path("fedora/44/x86_64"), + "packageRelease": "1.fc44", + "sourceRevision": False, + "packagePattern": re.compile( + r"packages/loopwire-{version}-1\.fc44\.x86_64\.rpm\Z" + ), + "label": "Fedora 44 x86_64", + "client": "DNF", + }, + "opensuse-tumbleweed-x86_64": { + "manifest": { + "distribution": "opensuse", "release": "tumbleweed", "architecture": "x86_64", + }, + "publicPrefix": Path("opensuse/tumbleweed/x86_64"), + "packageRelease": "1", + "sourceRevision": True, + "packagePattern": re.compile( + r"packages/loopwire-{version}-1\.x86_64\.rpm\Z" + ), + "label": "openSUSE Tumbleweed x86_64", + "client": "Zypper/libzypp", + }, +} 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" ) +# Compatibility aliases for existing Fedora callers and tests. +TARGET = TARGETS[DEFAULT_TARGET]["manifest"] +PUBLIC_PREFIX = TARGETS[DEFAULT_TARGET]["publicPrefix"] +PACKAGE_PATH = re.compile(rf"packages/loopwire-{VERSION}-1\.fc44\.x86_64\.rpm\Z") MANIFEST_FIELDS = { "schema", "schemaVersion", "revision", "signingFingerprint", "createdAt", "validUntil", "target", "packages", "files", @@ -72,6 +103,14 @@ def require(condition, message): raise PublicationError(message) +def target_config(target=DEFAULT_TARGET): + require(isinstance(target, str) and target in TARGETS, + "unsupported RPM repository target") + config = TARGETS[target] + pattern = config["packagePattern"].pattern.format(version=VERSION) + return target, config, re.compile(pattern) + + def canonical(value): return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") @@ -104,10 +143,11 @@ def safe_path(value): return path -def classify(path): +def classify(path, target=DEFAULT_TARGET): """Derive URL mutability from the protocol, never from manifest input.""" safe_path(path) - if PACKAGE_PATH.fullmatch(path): + _target, _config, package_path = target_config(target) + if package_path.fullmatch(path): return "immutable" if re.fullmatch(r"keys/(?:[A-F0-9]{40}|[A-F0-9]{64})\.asc", path): return "immutable" @@ -115,7 +155,7 @@ def classify(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") + raise PublicationError("inventory contains a path outside the selected RPM protocol") def plain_path(path, directory=False, missing=False): @@ -161,7 +201,8 @@ def read_json(path): raise PublicationError("invalid repository JSON") from error -def inventory(root, fingerprint): +def inventory(root, fingerprint, target=DEFAULT_TARGET): + target, config, package_path = target_config(target) root = plain_path(root, directory=True) actual = tree_files(root) require(MANIFEST in actual, "candidate is missing its manifest") @@ -178,8 +219,8 @@ def inventory(root, fingerprint): 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(manifest.get("target") == config["manifest"], + f"candidate must target {config['label']}") require(type(manifest.get("createdAt")) is int and type(manifest.get("validUntil")) is int and manifest["validUntil"] > manifest["createdAt"], "candidate validity interval is invalid") @@ -193,16 +234,16 @@ def inventory(root, fingerprint): require(isinstance(entry, dict), "invalid candidate file entry") require(set(entry) == FILE_FIELDS, "invalid candidate file fields") path = entry.get("path") - kind = classify(path) + kind = classify(path, target) 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 = root / path + require(path in actual and candidate_file.stat().st_size == entry["size"] + and digest(candidate_file) == entry["sha256"], "candidate file checksum or size differs") expected.add(path) indexed[path] = entry @@ -214,21 +255,24 @@ def inventory(root, fingerprint): "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 + expected_package_fields = (PACKAGE_FIELDS | {"sourceRevision"} + if config["sourceRevision"] else PACKAGE_FIELDS) + require(all(isinstance(item, dict) and set(item) == expected_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), + 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)}, + 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" + require(package["name"] == "loopwire" + and package["release"] == config["packageRelease"] and package["architecture"] == "x86_64" and re.fullmatch(VERSION, package["version"]) and package["path"] == ( - f"packages/loopwire-{package['version']}-1.fc44.x86_64.rpm" + f"packages/loopwire-{package['version']}-{config['packageRelease']}.x86_64.rpm" ), "candidate package identity is invalid") require(isinstance(package["sourceReleaseSha256"], str) and REVISION.fullmatch(package["sourceReleaseSha256"]) @@ -236,6 +280,10 @@ def inventory(root, fingerprint): and REVISION.fullmatch(package["distributedSha256"]) and type(package["size"]) is int and package["size"] >= 0, "candidate package hash or size is invalid") + if config["sourceRevision"]: + require(isinstance(package["sourceRevision"], str) + and re.fullmatch(r"[0-9a-f]{40}", package["sourceRevision"]), + "candidate source revision is invalid") entry = indexed[package["path"]] require(entry["sha256"] == package["distributedSha256"] and entry["size"] == package["size"], @@ -243,12 +291,14 @@ def inventory(root, fingerprint): return manifest -def verify_signed(root, key, fingerprint, historical=False): - manifest = inventory(root, fingerprint) +def verify_signed(root, key, fingerprint, historical=False, target=DEFAULT_TARGET): + manifest = inventory(root, fingerprint, target) 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 target != DEFAULT_TARGET: + command += ["--target", target] if historical: command += ["--now", str(manifest["createdAt"])] result = subprocess.run(command, capture_output=True, text=True, check=False) @@ -308,20 +358,31 @@ def root_path(value): return plain_path(path, directory=True, missing=True) -def public_channel(root): - return root / "public" / PUBLIC_PREFIX +def public_channel(root, target=DEFAULT_TARGET): + _target, config, _package_path = target_config(target) + return root / "public" / config["publicPrefix"] + + +def private_channel(root, target=DEFAULT_TARGET): + target, _config, _package_path = target_config(target) + return root if target == DEFAULT_TARGET else root / "channels" / target @contextlib.contextmanager -def locked(root, create=False): +def locked(root, create=False, target=DEFAULT_TARGET): + target, _config, _package_path = target_config(target) + private = private_channel(root, target) if create: make_directory(root) - if not root.exists(): + if target != DEFAULT_TARGET: + make_directory(private.parent, mode=0o700) + make_directory(private, mode=0o700) + if not root.exists() or not private.exists(): raise EmptyRepository("repository has no committed snapshot") - lock = root / ".publish.lock" + lock = private / ".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(), + require(not (private / "state").exists() and not public_channel(root, target).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) @@ -340,8 +401,8 @@ def locked(root, create=False): os.close(descriptor) -def state(root, name): - path = root / "state" / f"{name}.json" +def state(root, name, target=DEFAULT_TARGET): + path = private_channel(root, target) / "state" / f"{name}.json" plain_path(path, missing=True) if not path.exists(): return None @@ -366,18 +427,18 @@ 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) +def check_public(root, manifest, immutable_only=False, target=DEFAULT_TARGET): + channel = public_channel(root, target) 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() + public_file = channel / entry["path"] + plain_path(public_file, missing=True) + if public_file.exists(): + info = public_file.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"], + require(info.st_size == entry["size"] and digest(public_file) == entry["sha256"], "immutable URL collision" if immutable_only else "committed public repository has drifted") elif not immutable_only: @@ -390,13 +451,13 @@ def check_public(root, manifest, immutable_only=False): "committed public repository manifest has drifted") -def save_snapshot(root, repository, manifest): - snapshots = root / "snapshots" +def save_snapshot(root, repository, manifest, target=DEFAULT_TARGET): + snapshots = private_channel(root, target) / "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, + require(inventory(snapshot, manifest["signingFingerprint"], target) == manifest, "retained snapshot differs from candidate") return snapshot temporary = Path(tempfile.mkdtemp(prefix=".staging-", dir=snapshots)) @@ -407,7 +468,7 @@ def save_snapshot(root, repository, manifest): mode=0o600, directory_mode=0o700) atomic_write(temporary / MANIFEST, source=repository / MANIFEST, mode=0o600, directory_mode=0o700) - inventory(temporary, manifest["signingFingerprint"]) + inventory(temporary, manifest["signingFingerprint"], target) fsync_directory(temporary) os.replace(temporary, snapshot) fsync_directory(snapshots) @@ -417,20 +478,21 @@ def save_snapshot(root, repository, manifest): return snapshot -def promote(root, snapshot, manifest): +def promote(root, snapshot, manifest, target=DEFAULT_TARGET): """Publish immutable data, signature, then atomic repomd.xml commit.""" - channel = public_channel(root) + private = private_channel(root, target) + channel = public_channel(root, target) make_directory(channel) devices = {path.stat().st_dev for path in - (root, channel, root / "state", root / "snapshots")} + (root, channel, private / "state", private / "snapshots")} require(len(devices) == 1, "origin public, snapshot, and state paths must share one filesystem") - check_public(root, manifest, immutable_only=True) + check_public(root, manifest, immutable_only=True, target=target) for entry in manifest["files"]: if entry["kind"] == "immutable": - target = channel / entry["path"] - if not target.exists(): - atomic_write(target, source=snapshot / entry["path"]) + public_file = channel / entry["path"] + if not public_file.exists(): + atomic_write(public_file, source=snapshot / entry["path"]) _checkpoint("immutable") signature = "repodata/repomd.xml.asc" atomic_write(channel / signature, source=snapshot / signature) @@ -439,22 +501,25 @@ def promote(root, snapshot, manifest): atomic_write(channel / metadata, source=snapshot / metadata) _checkpoint("committed") atomic_write(channel / MANIFEST, source=snapshot / MANIFEST) - check_public(root, manifest) + check_public(root, manifest, target=target) _checkpoint("manifest") - atomic_write(root / "state" / "current.json", + atomic_write(private / "state" / "current.json", data=canonical({"revision": manifest["revision"]}) + b"\n", mode=0o600) _checkpoint("current") - (root / "state" / "pending.json").unlink() - fsync_directory(root / "state") + (private / "state" / "pending.json").unlink() + fsync_directory(private / "state") + _target, config, _package_path = target_config(target) return {"status": "published", "revision": manifest["revision"], - "target": TARGET.copy()} + "target": config["manifest"].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") +def publish_at(root, repository, fingerprint, expected, target=DEFAULT_TARGET): + target, config, _package_path = target_config(target) + private = private_channel(root, target) + manifest = inventory(repository, fingerprint, target) + with locked(root, create=True, target=target): + current = state(root, "current", target) + pending = state(root, "pending", target) revision = current["revision"] if current else "empty" if pending: require(pending["revision"] == manifest["revision"], @@ -463,48 +528,51 @@ def publish_at(root, repository, fingerprint, expected): "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, + snapshot = private / "snapshots" / pending["revision"] + require(inventory(snapshot, fingerprint, target) == manifest, "pending snapshot differs from candidate") - return promote(root, snapshot, manifest) + return promote(root, snapshot, manifest, target) if revision == manifest["revision"]: - check_public(root, manifest) + check_public(root, manifest, target=target) return {"status": "unchanged", "revision": revision, - "target": TARGET.copy()} + "target": config["manifest"].copy()} require(expected == revision, "expected revision differs from current publication (compare-and-swap failed)") if current is None: - channel = public_channel(root) + channel = public_channel(root, target) 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({ + "refusing to adopt an unmanaged public RPM repository") + check_public(root, manifest, immutable_only=True, target=target) + snapshot = save_snapshot(root, repository, manifest, target) + make_directory(private / "state", mode=0o700) + atomic_write(private / "state" / "pending.json", data=canonical({ "revision": manifest["revision"], "previousRevision": revision, }) + b"\n", mode=0o600) _checkpoint("journal") - return promote(root, snapshot, manifest) + return promote(root, snapshot, manifest, target) -def recover_at(root, fingerprint, revision): - with locked(root, create=True): - pending = state(root, "pending") +def recover_at(root, fingerprint, revision, target=DEFAULT_TARGET): + private = private_channel(root, target) + with locked(root, create=True, target=target): + pending = state(root, "pending", target) require(pending is not None and pending["revision"] == revision, "pending publication changed; fetch and verify it again before recovery") - current = state(root, "current") + current = state(root, "current", target) 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)) + snapshot = private / "snapshots" / revision + return promote(root, snapshot, inventory(snapshot, fingerprint, target), target) @contextlib.contextmanager -def selected_snapshot(root, fingerprint, revision=None, pending_only=False): - with locked(root): - pending = state(root, "pending") +def selected_snapshot(root, fingerprint, revision=None, pending_only=False, + target=DEFAULT_TARGET): + private = private_channel(root, target) + with locked(root, target=target): + pending = state(root, "pending", target) if pending_only: if not pending: raise EmptyRepository("repository has no pending publication") @@ -513,12 +581,12 @@ def selected_snapshot(root, fingerprint, revision=None, pending_only=False): require(pending is None, "interrupted publication pending; recover before fetching snapshots") if revision is None: - current = state(root, "current") + current = state(root, "current", target) if not current: raise EmptyRepository("repository has no committed snapshot") revision = current["revision"] - snapshot = root / "snapshots" / revision - manifest = inventory(snapshot, fingerprint) + snapshot = private / "snapshots" / revision + manifest = inventory(snapshot, fingerprint, target) require(manifest["revision"] == revision, "snapshot does not match selected revision") yield snapshot, manifest @@ -530,20 +598,20 @@ def write_archive(repository, output): archive.add(repository / relative, arcname=relative, recursive=False) -def read_archive(source, output): +def read_archive(source, output, target=DEFAULT_TARGET): 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), + require(member.name == MANIFEST or classify(member.name, target), "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: + destination_file = output / member.name + make_directory(destination_file.parent) + with archive.extractfile(member) as incoming, destination_file.open("xb") as destination: shutil.copyfileobj(incoming, destination, 1024 * 1024) @@ -594,7 +662,7 @@ def remote_call(args, request, repository=None, output=None): "SSH repository operation failed; check pinned host key, identity, connectivity, remote Python, and publisher state") outgoing.seek(0) if output: - read_archive(outgoing, output) + read_archive(outgoing, output, request.get("target", DEFAULT_TARGET)) return None try: return json.load(outgoing) @@ -604,6 +672,9 @@ def remote_call(args, request, repository=None, output=None): def serve(request): root = root_path(request["root"]) + target, _config, _package_path = target_config( + request.get("target", DEFAULT_TARGET) + ) fingerprint = request["fingerprint"] require(FINGERPRINT.fullmatch(fingerprint), "invalid signing fingerprint") action = request["action"] @@ -612,35 +683,38 @@ def serve(request): "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"]) + read_archive(sys.stdin.buffer, repository, target) + return publish_at(root, repository, fingerprint, request["expected"], target) if action == "recover": require(REVISION.fullmatch(request["revision"]), "invalid recovery revision") - return recover_at(root, fingerprint, request["revision"]) + return recover_at(root, fingerprint, request["revision"], target) 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, _): + action == "fetch-pending", target) as (snapshot, _): write_archive(snapshot, sys.stdout.buffer) return None def fetch_into(args, output, pending_only=False): + target = getattr(args, "target", DEFAULT_TARGET) if args.ssh: remote_call(args, { "action": "fetch-pending" if pending_only else "fetch", "root": args.root, + "target": target, "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, _): + pending_only, target) 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) + return verify_signed(output, args.public_key, args.fingerprint, + historical=historical, target=target) def parser(): @@ -653,6 +727,8 @@ def parser(): 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("--target", choices=tuple(TARGETS), default=DEFAULT_TARGET, + help="isolated repository target (defaults to Fedora 44 x86_64)") action.add_argument("--ssh", metavar="USER@HOST", help="omit for a local POSIX origin") action.add_argument("--ssh-port", type=int) @@ -680,6 +756,9 @@ def parser(): def run(args): + target, config, _package_path = target_config( + getattr(args, "target", DEFAULT_TARGET) + ) if args.ssh: requested_root = Path(args.root) require(requested_root.is_absolute() and args.root != "/" @@ -704,23 +783,26 @@ def run(args): 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) + manifest = verify_signed(args.repository, args.public_key, args.fingerprint, + target=target) 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, + require(verify_signed(candidate, args.public_key, args.fingerprint, + target=target) == manifest, "candidate changed during verification") if args.ssh: return remote_call(args, { "action": "publish", "root": args.root, + "target": target, "fingerprint": args.fingerprint, "expected": args.expected_revision, }, repository=candidate) return publish_at(root_path(args.root), candidate, args.fingerprint, - args.expected_revision) + args.expected_revision, target) if args.action == "fetch": require(args.revision is None or REVISION.fullmatch(args.revision), "invalid selected revision") @@ -744,16 +826,18 @@ def run(args): if args.ssh: result = remote_call(args, { "action": "recover", "root": args.root, + "target": target, "fingerprint": args.fingerprint, "revision": manifest["revision"], }) else: result = recover_at(root_path(args.root), args.fingerprint, - manifest["revision"]) + manifest["revision"], target) 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." + f"the project verifier rejects the expired snapshot. {config['client']} " + "signature checks do not enforce this project deadline." ) return result diff --git a/scripts/rpm-repository.py b/scripts/rpm-repository.py index 3ef1fa9..8b7de63 100644 --- a/scripts/rpm-repository.py +++ b/scripts/rpm-repository.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Build and verify immutable signed Loopwire Fedora repository candidates. +"""Build and verify immutable signed Loopwire RPM-md repository candidates. Requires Python's standard library, createrepo_c, rpm, rpmkeys, rpmsign, gpg, gpgv, and openssl. The publisher retains immutable package and metadata @@ -8,7 +8,7 @@ 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, +times. Byte-for-byte repeatability still requires the pinned target tool image, the same key material, and a deterministic OpenPGP algorithm; a prior package with identical source bytes is reused rather than signed again. """ @@ -32,7 +32,27 @@ SCHEMA = "loopwire.rpm-repository.v1" MANIFEST = "repository-manifest.json" -TARGET = {"distribution": "fedora", "release": "44", "architecture": "x86_64"} +RELEASE_MANIFEST_SCHEMA = "loopwire.release-assets.v1" +FEDORA = "fedora-44-x86_64" +OPENSUSE = "opensuse-tumbleweed-x86_64" +TARGETS = { + FEDORA: { + "manifest": {"distribution": "fedora", "release": "44", "architecture": "x86_64"}, + "rpmRelease": "1.fc44", + "filename": "loopwire-{version}-1.fc44.x86_64.rpm", + "distroCpe": "cpe:/o:fedoraproject:fedora:44", + "distroName": "Fedora 44", + "releaseManifest": False, + }, + OPENSUSE: { + "manifest": {"distribution": "opensuse", "release": "tumbleweed", "architecture": "x86_64"}, + "rpmRelease": "1", + "filename": "loopwire-{version}-1.x86_64.rpm", + "distroCpe": "cpe:/o:opensuse:tumbleweed", + "distroName": "openSUSE Tumbleweed", + "releaseManifest": True, + }, +} 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})" @@ -40,6 +60,7 @@ "name", "version", "release", "architecture", "path", "sourceReleaseSha256", "distributedSha256", "size", } +OPENSUSE_PACKAGE_FIELDS = PACKAGE_FIELDS | {"sourceRevision"} FILE_FIELDS = {"path", "sha256", "size", "kind"} ROOT = Path(__file__).resolve().parent.parent REPO_NS = "http://linux.duke.edu/metadata/repo" @@ -92,6 +113,22 @@ def hash_value(value, label): return value +def verify_release_signature(public_key, signature, checksums): + description = run( + "openssl", "pkey", "-pubin", "-in", public_key, "-text_pub", "-noout", + ).decode("utf-8", errors="replace") + match = re.search(r"^Public-Key: \(([0-9]+) bit\)$", description, re.MULTILINE) + require(match is not None and "Modulus:" in description and "Exponent:" in description, + "release verification key must be RSA") + expected_size = (int(match.group(1)) + 7) // 8 + require(signature.stat().st_size == expected_size, + "release checksum signature length does not match its RSA key") + run( + "openssl", "dgst", "-sha256", "-verify", public_key, + "-signature", signature, checksums, + ) + + def integer(value, label, minimum=0): require(type(value) is int and value >= minimum, f"invalid {label}") return value @@ -127,17 +164,34 @@ def safe_path(value): return value -def classify_path(value): +def target_config(slug): + require(slug in TARGETS, f"unsupported RPM repository target: {slug}") + return TARGETS[slug] + + +def target_from_manifest(value): + for slug, config in TARGETS.items(): + if value == config["manifest"]: + return slug, config + raise RepositoryError("repository target is unsupported") + + +def package_fields(config): + return OPENSUSE_PACKAGE_FIELDS if config["releaseManifest"] else PACKAGE_FIELDS + + +def classify_path(value, config): 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): + filename = re.escape(config["filename"]).replace(re.escape("{version}"), VERSION) + if re.fullmatch(rf"packages/{filename}", 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}") + raise RepositoryError(f"path is outside the RPM repository target contract: {value}") def regular_file(path): @@ -193,7 +247,7 @@ def manifest_revision(manifest): return hashlib.sha256(canonical(unsigned)).hexdigest() -def rpm_identity(path): +def rpm_identity(path, config): regular_file(path) fields = run( "rpm", "-qp", "--queryformat", @@ -203,17 +257,20 @@ def rpm_identity(path): 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(release == config["rpmRelease"], + f"repository only accepts RPM Release: {config['rpmRelease']}, 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): + available = set(run("rpm", "--querytags").decode("ascii").splitlines()) + tags = [tag for tag in ("OPENPGP", "SIGPGP", "SIGGPG", "RSAHEADER", "DSAHEADER") if tag in available] + require(tags, "RPM tool exposes no supported OpenPGP signature tags") output = run( "rpm", "-qp", "--queryformat", - "%{OPENPGP:pgpsig}\n%{SIGPGP:pgpsig}\n%{SIGGPG:pgpsig}\n" - "%{RSAHEADER:pgpsig}\n%{DSAHEADER:pgpsig}\n", path, + "".join(f"%{{{tag}:pgpsig}}\n" for tag in tags), path, ).decode("utf-8", errors="replace") return any(value.strip() not in ("", "(none)") for value in output.splitlines()) @@ -231,27 +288,30 @@ def verify_rpm_signature(path, public_key): run("rpmkeys", "--dbpath", database, "--checksig", path) -def package_info(root, path, source_hash, public_key): - classify_path(path) +def package_info(root, path, provenance, public_key, config): + classify_path(path, config) 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) + name, version, release, architecture = rpm_identity(package, config) require( Path(path).name == f"{name}-{version}-{release}.{architecture}.rpm", "RPM filename does not match its NEVRA identity", ) - return { + result = { "name": name, "version": version, "release": release, "architecture": architecture, "path": path, - "sourceReleaseSha256": hash_value(source_hash, "source release SHA256"), + "sourceReleaseSha256": hash_value(provenance["sha256"], "source release SHA256"), "distributedSha256": sha256(package), "size": package.stat().st_size, } + if config["releaseManifest"]: + result["sourceRevision"] = provenance["revision"] + return result def verify_detached_signature(data, signature, keyring, home, expected): @@ -278,7 +338,7 @@ def verify_detached_signature(data, signature, keyring, home, expected): ) -def load_inventory(root): +def load_inventory(root, expected_target=None): actual = tree_files(root) require(MANIFEST in actual, "missing repository-manifest.json") manifest = read_json(root / MANIFEST) @@ -295,8 +355,11 @@ def load_inventory(root): 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(isinstance(manifest["target"], dict), "repository target must be an object") + slug, config = target_from_manifest(manifest["target"]) + exact_keys(manifest["target"], config["manifest"], "repository target") + if expected_target is not None: + require(slug == expected_target, "repository target differs from operator pin") 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") @@ -305,7 +368,7 @@ def load_inventory(root): 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}") + require(entry["kind"] == classify_path(path, config), 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}") @@ -319,7 +382,7 @@ def load_inventory(root): f"repodata content filename/checksum mismatch: {path}") inventory[path] = entry require(actual == set(inventory) | {MANIFEST}, "repository has unlisted files") - return manifest, inventory + return manifest, inventory, config def xml(root, name): @@ -328,7 +391,7 @@ def xml(root, name): return values[0] -def parse_repomd(root, manifest, inventory): +def parse_repomd(root, manifest, inventory, config): path = root / "repodata/repomd.xml" raw = path.read_bytes() require(b"= previous["createdAt"], "new metadata date must not precede the previous revision") copy_immutable(previous_path, working, previous) @@ -694,7 +848,7 @@ def write_candidate(args, rollback=False): else: packages = [] if not rollback: - signed_release_package(args, working, packages, expected, key, staging, date) + signed_release_package(args, working, packages, expected, key, staging, date, config) require(packages, "repository package set must not be empty") exported = working / f"keys/{expected}.asc" exported.parent.mkdir(parents=True, exist_ok=True) @@ -703,11 +857,11 @@ def write_candidate(args, rollback=False): "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) + generate_metadata(working, packages, date, valid_until, gpg, expected, staging, config) files = [ { "path": path, "sha256": sha256(working / path), - "size": (working / path).stat().st_size, "kind": classify_path(path), + "size": (working / path).stat().st_size, "kind": classify_path(path, config), } for path in sorted(tree_files(working)) ] @@ -717,13 +871,13 @@ def write_candidate(args, rollback=False): "signingFingerprint": expected, "createdAt": date, "validUntil": valid_until, - "target": TARGET, + "target": config["manifest"], "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) + verify_repository(working, key, expected, date, target) working.rename(output) return manifest @@ -731,9 +885,11 @@ def write_candidate(args, rollback=False): 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 = commands.add_parser("build", help="generate an RPM-md candidate from signed native release assets") build.add_argument("--release-dir", type=Path, required=True) build.add_argument("--version", required=True) + build.add_argument("--target", choices=tuple(TARGETS), default=FEDORA, + help=f"repository target (default: {FEDORA})") build.add_argument( "--release-public-key", type=Path, default=ROOT / "packaging/release-signing-public.pem", help="trusted release checksum PEM (override for fixture keys)", @@ -741,6 +897,8 @@ def main(): 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) + rollback.add_argument("--target", choices=tuple(TARGETS), + help="optional target pin; otherwise derived from the retained snapshot") 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") @@ -750,15 +908,17 @@ def main(): 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 = commands.add_parser("verify", help="verify the complete pinned RPM 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("--target", choices=tuple(TARGETS), + help="optional target pin; otherwise derived from the manifest") 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) + manifest = verify_repository(args.repository, args.public_key, args.fingerprint, args.now, args.target) else: manifest = write_candidate(args, rollback=args.command == "rollback") print(json.dumps({ diff --git a/scripts/setup-opensuse-repository.sh b/scripts/setup-opensuse-repository.sh new file mode 100755 index 0000000..6a5c9b7 --- /dev/null +++ b/scripts/setup-opensuse-repository.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_url="" +fingerprint="" +install_root="/" +remove="false" +dry_run="false" + +fail() { printf 'setup-opensuse-repository: %s\n' "$*" >&2; exit 1; } +usage() { + cat <<'USAGE' +Configure Loopwire's signed project repository on openSUSE Tumbleweed x86_64. + +Usage: + sudo bash setup-opensuse-repository.sh --base-url HTTPS_URL --fingerprint OPENPGP_FINGERPRINT + sudo bash setup-opensuse-repository.sh --remove + bash setup-opensuse-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 Zypper repositories are preserved. +This writes only the repository and pinned key files. Refresh and install are separate reviewed steps. +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/zypp/repos.d/loopwire.repo" +key_directory="${install_root%/}/etc/zypp/keys" +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-opensuse-repository: refusing symbolic links inside the target Zypper configuration tree') + if not part.is_relative_to(root): + sys.exit('setup-opensuse-repository: configuration path leaves the target root') +PY +owner_marker="# Managed by Loopwire openSUSE 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 openSUSE repository and pinned key file.\n' + exit 0 + fi + if [ -f "$repo_file" ]; then + key_name="$(sed -n 's|^gpgkey=file:///etc/zypp/keys/\(loopwire-repository-[A-F0-9]*\.asc\)$|\1|p' "$repo_file")" + [[ "$key_name" =~ ^loopwire-repository-[A-F0-9]{40}\.asc$ ]] || fail "managed repository has an unexpected key path" + rm -- "$repo_file" + rm -f -- "$key_directory/$key_name" + fi + printf 'Loopwire openSUSE 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-opensuse-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') != 'opensuse-tumbleweed': + sys.exit('setup-opensuse-repository: supported system is openSUSE Tumbleweed') +PY +[ "$(rpm --eval '%{_arch}')" = x86_64 ] || fail "this channel currently supports x86_64 only" +key_name="loopwire-repository-${fingerprint}.asc" +[ ! -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 Tumbleweed x86_64 with strict 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" <", "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="opensuse-tumbleweed"\nVERSION_ID="20260829"\n') + self.repo = self.root / "etc/zypp/repos.d/loopwire.repo" + self.key = self.root / f"etc/zypp/keys/loopwire-repository-{self.fingerprint}.asc" + 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_is_strict_and_idempotent(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}", "autorefresh=1", "type=rpm-md", "priority=99", "gpgcheck=1", + "repo_gpgcheck=1", "pkg_gpgcheck=1", + f"gpgkey=file:///etc/zypp/keys/loopwire-repository-{self.fingerprint}.asc"]: + 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_platform_and_arch_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="opensuse-leap"\nVERSION_ID="16.0"\n') + self.assertNotEqual(self.invoke().returncode, 0) + (self.root / "etc/os-release").write_text('ID="opensuse-tumbleweed"\nVERSION_ID="20260829"\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=20260829\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/zypp/repos.d").rmdir() + (self.root / "etc/zypp/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-opensuse-public.py b/scripts/test-opensuse-public.py new file mode 100755 index 0000000..4f4a862 --- /dev/null +++ b/scripts/test-opensuse-public.py @@ -0,0 +1,130 @@ +#!/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-opensuse-public.py") +spec = importlib.util.spec_from_file_location("opensuse_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-opensuse-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": "opensuse", "release": "tumbleweed", "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"]) + self.assertIn("--target", verifier.call_args.args[0]) + self.assertIn("opensuse-tumbleweed-x86_64", verifier.call_args.args[0]) + return json.loads(output.getvalue()) + + def test_exact_public_bytes_produce_opensuse_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"], "opensuse-tumbleweed") + 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": "fedora", "release": "44", "architecture": "x86_64"} + (self.root / "repository-manifest.json").write_text(json.dumps(manifest)) + with self.assertRaisesRegex(ValueError, "openSUSE"): + self.invoke() + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-opensuse-repository-vm-proof.mjs b/scripts/test-opensuse-repository-vm-proof.mjs new file mode 100755 index 0000000..71ebbc6 --- /dev/null +++ b/scripts/test-opensuse-repository-vm-proof.mjs @@ -0,0 +1,182 @@ +#!/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, verifyReleaseSignature, + verifyRpmSignature } from "./verify-fedora-repository-vm-proof.mjs"; +import { verifyInstalledStage, verifyLifecycle, verifyPackageEntry, verifyReleaseAssetManifest, + verifyZypperInstalledSearch, verifyZypperSearch, targetManifestRow } from "./verify-opensuse-repository-vm-proof.mjs"; + +const directory = await mkdtemp(path.join(tmpdir(), "loopwire-opensuse-proof-test-")); +const baseline = "0.1.0-1"; +const upgrade = "0.1.0+zypperfixture1-1"; +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 V4 RSA/SHA256 Signature, key ID ${fingerprint.slice(-16).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: "opensuse-tumbleweed", 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) }; +const zypperXml = `\n`; +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\t(none)\n`, + "zypper-origin.tsv": `loopwire\t${baseline}\tx86_64\tLoopwire for openSUSE Tumbleweed - x86_64\t(none)\n`, + "zypper-search.xml": zypperXml, + "zypper-repository-search.xml": zypperXml.replace('status="installed" ', ''), + "zypper-info.txt": `Information for package loopwire:\nRepository : loopwire\nName : loopwire\nVersion : ${baseline}\nArch : x86_64\nVendor : (none)\nInstalled : Yes\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 upgraded rejected", () => assert.throws(() => verifyLifecycle( + transitions.replace(`rollback\t${baseline}`, `rollback\t${upgrade}`), baseline, upgrade), /lifecycle transitions/)); + await test("fabricated lifecycle pass rejected", () => assert.throws(() => verifyLifecycle("pass", baseline, upgrade), /lifecycle transitions/)); + await test("selected target manifest row accepted", () => assert.deepEqual(targetManifestRow( + `# header\nopensuse-tumbleweed\topenSUSE Tumbleweed\topensuse-tumbleweed\trpm\thttps://example.invalid/image\tsha256\t${"a".repeat(64)}\t2264\tbios\n`, + "opensuse-tumbleweed"), ["opensuse-tumbleweed", "openSUSE Tumbleweed", "opensuse-tumbleweed", "rpm", + "https://example.invalid/image", "sha256", "a".repeat(64), "2264", "bios"])); + await test("duplicate or malformed target manifest rows rejected", () => { + const row = `opensuse-tumbleweed\topenSUSE Tumbleweed\topensuse-tumbleweed\trpm\thttps://example.invalid/image\tsha256\t${"a".repeat(64)}\t2264\tbios`; + assert.throws(() => targetManifestRow(`${row}\n${row}\n`, "opensuse-tumbleweed"), /missing or duplicate/); + assert.throws(() => targetManifestRow(`${row}\textra\n`, "opensuse-tumbleweed"), /nine nonempty fields/); + }); + await test("duplicate installed hash rejected", () => assert.throws(() => parseInstalledHashes( + `${"a".repeat(64)} /usr/bin/loopwire\n${"b".repeat(64)} /usr/bin/loopwire\n`), /duplicate/)); + await test("selective public release checksums accepted", () => assert.equal(parseReleaseChecksums( + `${sourceSha256} ${packageName}\n${"d".repeat(64)} loopwire-linux-x86_64.tar.gz\n`).get(packageName), sourceSha256)); + await test("duplicate public release checksum rejected", () => assert.throws(() => parseReleaseChecksums( + `${sourceSha256} ${packageName}\n${sourceSha256} ${packageName}\n`), /duplicate/)); + await test("valid release signature accepted and changed manifest 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 openSUSE release manifest accepted", () => verifyReleaseAssetManifest( + JSON.stringify(releaseManifest), releaseExpected)); + await test("wrong release manifest target rejected", () => assert.throws(() => verifyReleaseAssetManifest( + JSON.stringify({ ...releaseManifest, artifacts: releaseManifest.artifacts.map((entry, index) => index ? entry : { ...entry, target: "fedora-44" }) }), + releaseExpected), /artifact count/)); + await test("wrong release manifest hash rejected", () => assert.throws(() => verifyReleaseAssetManifest( + JSON.stringify({ ...releaseManifest, artifacts: releaseManifest.artifacts.map((entry, index) => index ? entry : { ...entry, sha256: "0".repeat(64) }) }), + releaseExpected), /openSUSE release artifact/)); + await test("wrong release manifest 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("portable RELEASE accepted", () => parsePayloadRelease(releaseText, "0.1.0")); + await test("wrong portable RELEASE rejected", () => assert.throws(() => parsePayloadRelease( + releaseText.replace("version=0.1.0", "version=0.2.0"), "0.1.0"), /RELEASE version/)); + await test("exact Zypper origin accepted", () => verifyZypperSearch(zypperXml, baseline)); + await test("native installed-system Zypper view accepted", () => verifyZypperInstalledSearch( + zypperXml.replace('repository="Loopwire for openSUSE Tumbleweed - x86_64"', 'repository="(System Packages)"'), + baseline)); + await test("wrong Zypper repository rejected", () => assert.throws(() => verifyZypperSearch( + zypperXml.replace('repository="Loopwire for openSUSE Tumbleweed - x86_64"', 'repository="@System"'), baseline), + /Zypper repository/)); + await test("valid RPM signature accepted", () => verifyRpmSignature(signature, fingerprint, packageName)); + await test("unsigned RPM rejected", () => assert.throws(() => verifyRpmSignature( + `${packageName}: digests OK\n`, fingerprint, packageName), /lacks/)); + const packageEntry = { name: "loopwire", version: "0.1.0", release: "1", architecture: "x86_64", + path: `packages/${packageName}`, sourceReleaseSha256: sourceSha256, sourceRevision: "e".repeat(40), + distributedSha256: packageSha256, size: 123 }; + await test("exact repository package accepted", () => verifyPackageEntry(packageEntry, + { version: "0.1.0", name: packageName, sourceRevision: "e".repeat(40) }, packageSha256, sourceSha256, "fixture")); + await test("public baseline source substitution rejected", () => assert.throws(() => verifyPackageEntry( + { ...packageEntry, sourceReleaseSha256: "d".repeat(64) }, { version: "0.1.0", name: packageName, sourceRevision: "e".repeat(40) }, + packageSha256, sourceSha256, "fixture"), /source release hash/)); + await test("public source revision substitution rejected", () => assert.throws(() => verifyPackageEntry( + { ...packageEntry, sourceRevision: "f".repeat(40) }, + { version: "0.1.0", name: packageName, sourceRevision: "e".repeat(40) }, + packageSha256, sourceSha256, "fixture"), /public source revision/)); + await test("distributed package substitution rejected", () => assert.throws(() => verifyPackageEntry( + { ...packageEntry, distributedSha256: "d".repeat(64) }, + { version: "0.1.0", name: packageName, sourceRevision: "e".repeat(40) }, + packageSha256, sourceSha256, "fixture"), /distributed RPM hash/)); + + await stageFixture(); + await test("complete 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), /metadata/], + ["wrong vendor rejected", "package-metadata.tsv", (value) => value.replace("(none)", "Example Vendor"), /vendor/], + ["local package origin rejected", "zypper-origin.tsv", (value) => value.replace("\tLoopwire for openSUSE Tumbleweed - x86_64\t(none)", "\t@System\t(none)"), /origin/], + ["wrong Zypper XML origin rejected", "zypper-repository-search.xml", (value) => value.replace('repository="Loopwire for openSUSE Tumbleweed - x86_64"', 'repository="other"'), /Zypper repository/], + ["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", ": NOKEY"), /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 launch 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 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(`openSUSE VM proof verifier tests passed: ${passed}`); +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/scripts/test-opensuse-workflow-preflight.py b/scripts/test-opensuse-workflow-preflight.py new file mode 100755 index 0000000..5144fa6 --- /dev/null +++ b/scripts/test-opensuse-workflow-preflight.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + + +SCRIPT = Path(__file__).with_name("publish-opensuse-workflow.sh").resolve() + + +class PreflightTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="loopwire-opensuse-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 > "$OPENSUSE_TEST_MARKER"\nexit 1\n') + gpg.chmod(0o755) + self.marker = self.root / "used-key" + self.env = { + **os.environ, "PATH": f"{binary}:{os.environ['PATH']}", "OPENSUSE_TEST_MARKER": str(self.marker), + "OPENSUSE_REPOSITORY_URL": "https://packages.example.invalid/opensuse/tumbleweed/x86_64", + "OPENSUSE_REPOSITORY_HOST": "publisher@example.invalid", + "OPENSUSE_REPOSITORY_ROOT": "/srv/loopwire-rpm", + "OPENSUSE_SIGNING_FINGERPRINT": "A" * 40, + "OPENSUSE_SSH_PRIVATE_KEY": "private-ssh-fixture", + "OPENSUSE_SSH_KNOWN_HOSTS": "known-hosts-fixture", + "OPENSUSE_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({"OPENSUSE_REPOSITORY_URL": url}, "base URL") + + def test_missing_configuration(self): + self.rejected({"OPENSUSE_SIGNING_KEY": ""}, "missing configuration: OPENSUSE_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({"OPENSUSE_SIGNING_FINGERPRINT": "short"}, "fingerprint") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-opensuse-workflow.rb b/scripts/test-opensuse-workflow.rb new file mode 100755 index 0000000..29f27d1 --- /dev/null +++ b/scripts/test-opensuse-workflow.rb @@ -0,0 +1,42 @@ +#!/usr/bin/env ruby +require 'yaml' + +root = File.expand_path('..', __dir__) +workflow = YAML.safe_load_file(File.join(root, '.github/workflows/publish-opensuse.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'), 'openSUSE 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'] == '17 6 * * 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.OPENSUSE_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?(/^opensuse\/tumbleweed@sha256:[a-f0-9]{64}$/), 'workflow must pin its Tumbleweed toolchain') +publisher = job.fetch('steps').find { |step| step['run'] == 'bash scripts/publish-opensuse-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[OPENSUSE_SIGNING_KEY OPENSUSE_SSH_PRIVATE_KEY OPENSUSE_SSH_KNOWN_HOSTS OPENSUSE_SIGNING_PASSPHRASE].each do |name| + check.call(publisher.dig('env', name) == "${{ secrets.#{name} }}", "#{name} must come from secrets") +end +caller = release.dig('jobs', 'publish-opensuse') +check.call(caller['needs'] == 'publish-release', 'openSUSE publication must wait for existing release gates') +check.call(caller['uses'] == './.github/workflows/publish-opensuse.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-opensuse-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-opensuse-public.py') > publish_index, 'activation requires verification of served bytes') +check.call(script.include?('trap cleanup EXIT') && script.include?('unset OPENSUSE_SSH_PRIVATE_KEY'), 'private files/environment need cleanup') +check.call(script.scan('--target "$target"').length >= 2, 'all repository operations must select the openSUSE target') +puts 'openSUSE 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 index 9915166..4560c31 100644 --- a/scripts/test-publish-rpm-repository.py +++ b/scripts/test-publish-rpm-repository.py @@ -12,7 +12,9 @@ import argparse import base64 import fcntl +import functools import hashlib +import http.server import importlib.util import io import json @@ -25,6 +27,7 @@ import sys import tarfile import tempfile +import threading import time import unittest import urllib.error @@ -35,6 +38,7 @@ SCRIPT = Path(__file__).with_name("publish-rpm-repository.py") WITH_SSH = False FPR = "A" * 40 +SUSE = "opensuse-tumbleweed-x86_64" def load(name, path): @@ -47,6 +51,11 @@ def load(name, path): publisher = load("rpm_publisher", SCRIPT) +class QuietHttpHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *_args): + pass + + def write_manifest(root, manifest): manifest.pop("revision", None) manifest["revision"] = hashlib.sha256(publisher.canonical(manifest)).hexdigest() @@ -54,9 +63,11 @@ def write_manifest(root, manifest): return manifest -def fixture(root, version="1.0.0", created=1, package_bytes=None): +def fixture(root, version="1.0.0", created=1, package_bytes=None, + target=publisher.DEFAULT_TARGET): + target, config, _package_path = publisher.target_config(target) root.mkdir() - package = f"packages/loopwire-{version}-1.fc44.x86_64.rpm" + package = f"packages/loopwire-{version}-{config['packageRelease']}.x86_64.rpm" files = { package: package_bytes or f"rpm package {version}".encode(), f"keys/{FPR}.asc": b"synthetic public key", @@ -68,33 +79,36 @@ def fixture(root, version="1.0.0", created=1, package_bytes=None): 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) + candidate_file = root / path + candidate_file.parent.mkdir(parents=True, exist_ok=True) + candidate_file.write_bytes(data) entries.append({ "path": path, - "kind": publisher.classify(path), + "kind": publisher.classify(path, target), "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), }) package_data = files[package] + package_record = { + "name": "loopwire", + "version": version, + "release": config["packageRelease"], + "architecture": "x86_64", + "path": package, + "sourceReleaseSha256": "1" * 64, + "distributedSha256": hashlib.sha256(package_data).hexdigest(), + "size": len(package_data), + } + if config["sourceRevision"]: + package_record["sourceRevision"] = "2" * 40 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), - }], + "target": config["manifest"].copy(), + "packages": [package_record], "files": entries, } return json.loads(json.dumps(write_manifest(root, manifest))) @@ -405,7 +419,7 @@ def interrupt(label): known_hosts=None, action="recover", dry_run=True, allow_expired=False) - def verify(_root, _key, _fingerprint, historical=False): + def verify(_root, _key, _fingerprint, historical=False, target=publisher.DEFAULT_TARGET): if not historical: raise publisher.PublicationError("expired repository") return manifest @@ -420,6 +434,209 @@ def verify(_root, _key, _fingerprint, historical=False): completed = publisher.run(base) self.assertTrue(completed["requiresRefresh"]) self.assertIn("Immediately", completed["nextAction"]) + self.assertIn("DNF", completed["nextAction"]) + + +class TargetIsolationTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="loopwire-rpm-target-tests-") + self.directory = Path(self.temporary.name) + self.root = self.directory / "origin" + self.fedora = self.directory / "fedora" + self.opensuse = self.directory / "opensuse" + self.opensuse_upgrade = self.directory / "opensuse-upgrade" + self.fedora_manifest = fixture(self.fedora) + self.opensuse_manifest = fixture(self.opensuse, target=SUSE) + self.opensuse_upgrade_manifest = fixture( + self.opensuse_upgrade, "1.1.0", 2, target=SUSE, + ) + + def tearDown(self): + self.temporary.cleanup() + + def test_target_validation_happens_before_origin_write(self): + with self.assertRaisesRegex(publisher.PublicationError, "Fedora 44"): + publisher.publish_at(self.root, self.opensuse, FPR, "empty") + self.assertFalse(self.root.exists()) + with self.assertRaisesRegex(publisher.PublicationError, "openSUSE"): + publisher.publish_at(self.root, self.fedora, FPR, "empty", SUSE) + self.assertFalse(self.root.exists()) + manifest = json.loads((self.opensuse / publisher.MANIFEST).read_text()) + manifest["packages"][0]["sourceRevision"] = "not-a-commit" + write_manifest(self.opensuse, manifest) + with self.assertRaisesRegex(publisher.PublicationError, "source revision"): + publisher.publish_at(self.root, self.opensuse, FPR, "empty", SUSE) + self.assertFalse(self.root.exists()) + + def test_independent_public_state_snapshot_lock_cas_and_idempotence(self): + publisher.publish_at(self.root, self.fedora, FPR, "empty") + result = publisher.publish_at(self.root, self.opensuse, FPR, "empty", SUSE) + self.assertEqual(result["target"], publisher.TARGETS[SUSE]["manifest"]) + self.assertEqual( + publisher.state(self.root, "current")["revision"], + self.fedora_manifest["revision"], + ) + self.assertEqual( + publisher.state(self.root, "current", SUSE)["revision"], + self.opensuse_manifest["revision"], + ) + self.assertTrue((self.root / "snapshots" / self.fedora_manifest["revision"]).is_dir()) + self.assertTrue((publisher.private_channel(self.root, SUSE) / "snapshots" + / self.opensuse_manifest["revision"]).is_dir()) + self.assertTrue((publisher.public_channel(self.root) / "repodata/repomd.xml").is_file()) + self.assertTrue((publisher.public_channel(self.root, SUSE) + / "repodata/repomd.xml").is_file()) + self.assertEqual( + publisher.publish_at(self.root, self.opensuse, FPR, "empty", SUSE)["status"], + "unchanged", + ) + with self.assertRaisesRegex(publisher.PublicationError, "compare-and-swap"): + publisher.publish_at( + self.root, self.opensuse_upgrade, FPR, + self.fedora_manifest["revision"], SUSE, + ) + self.assertEqual( + publisher.state(self.root, "current", SUSE)["revision"], + self.opensuse_manifest["revision"], + ) + + # A held Fedora writer lock does not serialize the disjoint openSUSE + # namespace; openSUSE still uses its own exclusive writer lock. + with publisher.locked(self.root, create=True): + publisher.publish_at( + self.root, self.opensuse_upgrade, FPR, + self.opensuse_manifest["revision"], SUSE, + ) + with publisher.locked(self.root, create=True, target=SUSE): + with self.assertRaisesRegex(publisher.PublicationError, "locked"): + publisher.publish_at( + self.root, self.opensuse, FPR, + self.opensuse_upgrade_manifest["revision"], SUSE, + ) + + def test_empty_opensuse_fetch_does_not_create_state_beside_fedora(self): + publisher.publish_at(self.root, self.fedora, FPR, "empty") + with self.assertRaises(publisher.EmptyRepository): + with publisher.selected_snapshot( + self.root, FPR, target=SUSE): + pass + self.assertFalse(publisher.private_channel(self.root, SUSE).exists()) + self.assertEqual( + publisher.state(self.root, "current")["revision"], + self.fedora_manifest["revision"], + ) + + def test_opensuse_retention_collision_and_explicit_rollback(self): + publisher.publish_at(self.root, self.opensuse, FPR, "empty", SUSE) + publisher.publish_at( + self.root, self.opensuse_upgrade, FPR, + self.opensuse_manifest["revision"], SUSE, + ) + channel = publisher.public_channel(self.root, SUSE) + old_package = self.opensuse_manifest["packages"][0]["path"] + new_package = self.opensuse_upgrade_manifest["packages"][0]["path"] + self.assertTrue((channel / old_package).is_file()) + self.assertTrue((channel / new_package).is_file()) + + collision = self.directory / "collision" + collision_manifest = fixture( + collision, "1.1.0", 3, b"changed bytes at an immutable openSUSE URL", + target=SUSE, + ) + with self.assertRaisesRegex(publisher.PublicationError, "immutable URL collision"): + publisher.publish_at( + self.root, collision, FPR, + self.opensuse_upgrade_manifest["revision"], SUSE, + ) + self.assertFalse((publisher.private_channel(self.root, SUSE) / "snapshots" + / collision_manifest["revision"]).exists()) + + rollback = self.directory / "rollback" + rollback_manifest = fixture(rollback, "1.0.0", 4, target=SUSE) + publisher.publish_at( + self.root, rollback, FPR, + self.opensuse_upgrade_manifest["revision"], SUSE, + ) + self.assertEqual( + publisher.state(self.root, "current", SUSE)["revision"], + rollback_manifest["revision"], + ) + self.assertTrue((channel / new_package).is_file(), + "rollback must retain newer immutable package URLs") + + def test_every_opensuse_checkpoint_recovers_without_fedora_state(self): + checkpoints = ("journal", "immutable", "signature", "committed", "manifest", "current") + for index, checkpoint in enumerate(checkpoints): + with self.subTest(checkpoint=checkpoint): + root = self.directory / f"interrupted-{index}" + + def interrupt(label): + if label == checkpoint: + raise InterruptedError("openSUSE publication interrupted") + + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at(root, self.opensuse, FPR, "empty", SUSE) + self.assertIsNone(publisher.state(root, "current")) + self.assertEqual( + publisher.state(root, "pending", SUSE)["revision"], + self.opensuse_manifest["revision"], + ) + publisher.recover_at(root, FPR, self.opensuse_manifest["revision"], SUSE) + self.assertEqual( + publisher.state(root, "current", SUSE)["revision"], + self.opensuse_manifest["revision"], + ) + self.assertIsNone(publisher.state(root, "pending", SUSE)) + + def test_opensuse_private_paths_ignore_restrictive_umask(self): + previous_umask = os.umask(0o077) + try: + publisher.publish_at(self.root, self.opensuse, FPR, "empty", SUSE) + finally: + os.umask(previous_umask) + private = publisher.private_channel(self.root, SUSE) + self.assertEqual(stat.S_IMODE((self.root / "channels").stat().st_mode), 0o700) + for path in (private, *private.rglob("*")): + expected = 0o700 if path.is_dir() else 0o600 + self.assertEqual(stat.S_IMODE(path.stat().st_mode), expected, str(path)) + public = publisher.public_channel(self.root, SUSE) + for path in (public, *public.rglob("*")): + expected = 0o755 if path.is_dir() else 0o644 + self.assertEqual(stat.S_IMODE(path.stat().st_mode), expected, str(path)) + + def test_expired_opensuse_recovery_names_zypper_refresh_boundary(self): + expired = self.directory / "opensuse-expired" + manifest = fixture( + expired, "2.0.0", int(time.time()) - 2592001, target=SUSE, + ) + + def interrupt(label): + if label == "journal": + raise InterruptedError("expired openSUSE pending journal") + + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at(self.root, expired, FPR, "empty", SUSE) + key = self.directory / "opensuse-key.asc" + key.write_text("synthetic", encoding="utf-8") + args = argparse.Namespace( + root=str(self.root), public_key=key, fingerprint=FPR, + target=SUSE, ssh=None, ssh_port=None, identity_file=None, + known_hosts=None, action="recover", dry_run=False, + allow_expired=True, + ) + + def verify(_root, _key, _fingerprint, historical=False, target=None): + self.assertEqual(target, SUSE) + self.assertTrue(historical) + return manifest + + with mock.patch.object(publisher, "verify_signed", side_effect=verify): + completed = publisher.run(args) + self.assertTrue(completed["requiresRefresh"]) + self.assertIn("Zypper/libzypp", completed["nextAction"]) + self.assertNotIn("DNF", completed["nextAction"]) class SignedDnfCommitTests(unittest.TestCase): @@ -613,6 +830,190 @@ def test_signed_cli_publish_fetch_idempotence_and_retained_revision(self): (self.first / publisher.MANIFEST).read_bytes()) +class SignedZypperCommitTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + if not WITH_SSH: + raise unittest.SkipTest("runs with --with-ssh in the pinned openSUSE tools container") + fixtures = load( + "opensuse_repository_test_fixtures", + SCRIPT.with_name("test-rpm-repository.py"), + ).OpenSUSERepositoryTests + fixtures.setUpClass() + cls.fixtures = fixtures + cls.directory = fixtures.root / "publisher-integration" + cls.directory.mkdir() + cls.gnupg = fixtures.gnupg + cls.fingerprint = fixtures.fingerprint + cls.public_key = fixtures.key + cls.date = fixtures.date + cls.first = fixtures.base + cls.second = cls.directory / "candidate-opensuse-1.1.0" + fixtures.build( + fixtures.release2, "1.1.0", cls.second, + "--previous", cls.first, "--date", str(cls.date + 1), + ) + cls.rollback = cls.directory / "candidate-rollback" + cls.shell( + sys.executable, SCRIPT.with_name("rpm-repository.py"), "rollback", + "--repository", cls.first, "--target", SUSE, + "--output", cls.rollback, "--signing-key", cls.fingerprint, + "--gnupg-home", cls.gnupg, "--date", str(cls.date + 2), + "--valid-for-days", "30", + ) + 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")) + cls.rolled = json.loads((cls.rollback / publisher.MANIFEST).read_text(encoding="utf-8")) + + @classmethod + def tearDownClass(cls): + if hasattr(cls, "fixtures"): + cls.fixtures.tearDownClass() + + @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 + + def setUp(self): + self.case_dir = self.directory / self.id().split(".")[-1] + self.case_dir.mkdir() + self.root = self.case_dir / "origin" + + def publisher_cli(self, action, *extra, ok=True): + result = self.shell( + sys.executable, SCRIPT, action, "--target", SUSE, + "--root", self.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 zypper(self, label, command, base_url=None): + state = self.case_dir / f"zypper-{label}" + repos = state / "repos" + for path in (state, repos, state / "cache", state / "raw", state / "solv", state / "packages"): + path.mkdir(exist_ok=True) + channel = publisher.public_channel(self.root, SUSE) + base_url = base_url or f"file://{channel}/" + key_url = base_url + f"keys/{self.fingerprint}.asc" + (repos / "loopwire.repo").write_text( + "[loopwire]\nname=Loopwire test\nenabled=1\nautorefresh=0\ntype=rpm-md\n" + f"baseurl={base_url}\n" + f"gpgkey={key_url}\n" + "gpgcheck=1\nrepo_gpgcheck=1\npkg_gpgcheck=1\n", + encoding="utf-8", + ) + args = [ + "zypper", "--non-interactive", "--gpg-auto-import-keys", + "--disable-system-resolvables", "--reposd-dir", repos, + "--cache-dir", state / "cache", "--raw-cache-dir", state / "raw", + "--solv-cache-dir", state / "solv", "--pkg-cache-dir", state / "packages", + ] + return self.shell(*args, *command, ok=False) + + def test_signed_cli_publish_fetch_upgrade_retention_and_rollback(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.root.exists()) + self.publisher_cli( + "publish", "--repository", self.first, "--expected-revision", "empty", + ) + fetched = self.case_dir / "fetched" + self.publisher_cli("fetch", "--output", fetched) + self.assertEqual((fetched / publisher.MANIFEST).read_bytes(), + (self.first / publisher.MANIFEST).read_bytes()) + self.publisher_cli( + "publish", "--repository", self.second, + "--expected-revision", self.one["revision"], + ) + self.publisher_cli( + "publish", "--repository", self.rollback, + "--expected-revision", self.two["revision"], + ) + channel = publisher.public_channel(self.root, SUSE) + for manifest in (self.one, self.two): + for package in manifest["packages"]: + self.assertTrue((channel / package["path"]).is_file()) + self.assertEqual( + publisher.state(self.root, "current", SUSE)["revision"], + self.rolled["revision"], + ) + + def test_real_zypper_rejects_mixed_metadata_then_accepts_recovery_and_rollback(self): + publisher.publish_at(self.root, self.first, self.fingerprint, "empty", SUSE) + handler = functools.partial( + QuietHttpHandler, directory=str(self.root / "public"), + ) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base_url = ( + f"http://127.0.0.1:{server.server_port}/opensuse/tumbleweed/x86_64/" + ) + try: + refreshed = self.zypper("initial", ["refresh"], base_url) + self.assertEqual(refreshed.returncode, 0, refreshed.stdout + refreshed.stderr) + searched = self.zypper( + "initial-search", ["search", "--details", "loopwire"], base_url, + ) + self.assertEqual(searched.returncode, 0, searched.stdout + searched.stderr) + self.assertIn("1.0.0", searched.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( + self.root, self.second, self.fingerprint, + self.one["revision"], SUSE, + ) + mixed = self.zypper("mixed", ["refresh"], base_url) + self.assertNotEqual(mixed.returncode, 0, mixed.stdout + mixed.stderr) + self.assertRegex( + mixed.stdout + mixed.stderr, + r"(?i)(signature|verification).*(fail|invalid)", + ) + + publisher.recover_at(self.root, self.fingerprint, self.two["revision"], SUSE) + recovered = self.zypper("recovered", ["refresh"], base_url) + self.assertEqual(recovered.returncode, 0, recovered.stdout + recovered.stderr) + searched = self.zypper( + "recovered-search", ["search", "--details", "loopwire"], base_url, + ) + self.assertIn("1.1.0", searched.stdout) + + publisher.publish_at( + self.root, self.rollback, self.fingerprint, + self.two["revision"], SUSE, + ) + rolled = self.zypper("rollback", ["refresh"], base_url) + self.assertEqual(rolled.returncode, 0, rolled.stdout + rolled.stderr) + searched = self.zypper( + "rollback-search", ["search", "--details", "loopwire"], base_url, + ) + self.assertIn("1.0.0", searched.stdout) + self.assertNotIn("1.1.0", searched.stdout) + finally: + server.shutdown() + server.server_close() + thread.join() + + class SshPublicationTests(unittest.TestCase): def test_actual_ssh_publish_fetch_and_recover_without_remote_gpg(self): if not WITH_SSH: @@ -633,6 +1034,10 @@ def test_actual_ssh_publish_fetch_and_recover_without_remote_gpg(self): second = directory / "second" one = fixture(first) two = fixture(second, "1.1.0", 2) + opensuse_first = directory / "opensuse-first" + opensuse_second = directory / "opensuse-second" + opensuse_one = fixture(opensuse_first, target=SUSE) + opensuse_two = fixture(opensuse_second, "1.1.0", 2, target=SUSE) origin = directory / "origin" identity = directory / "identity" host_key = directory / "host-key" @@ -647,13 +1052,20 @@ def test_actual_ssh_publish_fetch_and_recover_without_remote_gpg(self): remote_bin = directory / "remote-bin" remote_bin.mkdir() (remote_bin / "python3").symlink_to(sys.executable) + force_command = directory / "force-command" + force_command.write_text( + "#!/bin/sh\n" + f"PATH={remote_bin} exec /bin/sh -c \"$SSH_ORIGINAL_COMMAND\"\n", + encoding="utf-8", + ) + force_command.chmod(0o700) 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") + f"LogLevel ERROR\nForceCommand {force_command}\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)], @@ -708,6 +1120,44 @@ def interrupt(label): }) self.assertEqual(result["revision"], two["revision"]) self.assertIsNone(publisher.state(origin, "pending")) + + result = publisher.remote_call(connection, { + "action": "publish", "root": str(origin), "target": SUSE, + "fingerprint": FPR, "expected": "empty", + }, repository=opensuse_first) + self.assertEqual(result["revision"], opensuse_one["revision"]) + opensuse_fetched = directory / "opensuse-fetched" + opensuse_fetched.mkdir() + publisher.remote_call(connection, { + "action": "fetch", "root": str(origin), "target": SUSE, + "fingerprint": FPR, "revision": None, + }, output=opensuse_fetched) + self.assertEqual( + (opensuse_fetched / publisher.MANIFEST).read_bytes(), + (opensuse_first / publisher.MANIFEST).read_bytes(), + ) + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at( + origin, opensuse_second, FPR, + opensuse_one["revision"], SUSE, + ) + opensuse_pending = directory / "opensuse-pending" + opensuse_pending.mkdir() + publisher.remote_call(connection, { + "action": "fetch-pending", "root": str(origin), "target": SUSE, + "fingerprint": FPR, "revision": None, + }, output=opensuse_pending) + result = publisher.remote_call(connection, { + "action": "recover", "root": str(origin), "target": SUSE, + "fingerprint": FPR, "revision": opensuse_two["revision"], + }) + self.assertEqual(result["revision"], opensuse_two["revision"]) + self.assertIsNone(publisher.state(origin, "pending", SUSE)) + self.assertEqual( + publisher.state(origin, "current")["revision"], two["revision"], + "openSUSE SSH operations must not alter Fedora state", + ) known.write_text("", encoding="utf-8") with self.assertRaisesRegex(publisher.PublicationError, "SSH"): publisher.remote_call(connection, { @@ -731,7 +1181,10 @@ def test_syntax_and_live_cache_headers(self): origin = directory / "origin" candidate = directory / "candidate" manifest = fixture(candidate) + opensuse_candidate = directory / "opensuse-candidate" + opensuse_manifest = fixture(opensuse_candidate, target=SUSE) publisher.publish_at(origin, candidate, FPR, "empty") + publisher.publish_at(origin, opensuse_candidate, FPR, "empty", SUSE) snippet = directory / "nginx-rpm.conf" snippet.write_text( snippet_path.read_text(encoding="utf-8").replace( @@ -753,7 +1206,8 @@ def test_syntax_and_live_cache_headers(self): stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) try: - base = f"http://127.0.0.1:{port}/fedora/44/x86_64/" + host = f"http://127.0.0.1:{port}/" + base = host + "fedora/44/x86_64/" for _ in range(100): try: urllib.request.urlopen(base + "repodata/repomd.xml", timeout=0.1).close() @@ -763,24 +1217,48 @@ def test_syntax_and_live_cache_headers(self): self.fail(server.stderr.read()) time.sleep(0.02) - def headers(path): - with urllib.request.urlopen(base + path, timeout=2) as response: + def headers(base_url, path): + with urllib.request.urlopen(base_url + 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"]) + self.assertIn("no-store", headers(base, "repodata/repomd.xml")["Cache-Control"]) + self.assertIn("no-store", headers(base, "repodata/repomd.xml.asc")["Cache-Control"]) package = manifest["packages"][0]["path"] - self.assertIn("immutable", headers(package)["Cache-Control"]) + self.assertIn("immutable", headers(base, 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"]) + self.assertIn("immutable", headers(base, 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() + + opensuse_base = host + "opensuse/tumbleweed/x86_64/" + self.assertIn("no-store", headers( + opensuse_base, "repodata/repomd.xml")["Cache-Control"]) + self.assertIn("no-store", headers( + opensuse_base, "repodata/repomd.xml.asc")["Cache-Control"]) + self.assertIn("immutable", headers( + opensuse_base, + opensuse_manifest["packages"][0]["path"], + )["Cache-Control"]) + opensuse_hashed = next( + entry["path"] for entry in opensuse_manifest["files"] + if entry["path"].endswith("primary.xml.gz") + ) + self.assertIn("immutable", headers( + opensuse_base, opensuse_hashed)["Cache-Control"]) + with self.assertRaises(urllib.error.HTTPError) as opensuse_missing: + urllib.request.urlopen( + opensuse_base + "packages/loopwire-9.9.9-1.x86_64.rpm", + timeout=2, + ) + self.assertEqual(opensuse_missing.exception.code, 404) + self.assertIn("no-store", opensuse_missing.exception.headers["Cache-Control"]) + opensuse_missing.exception.close() finally: server.terminate() server.wait(timeout=10) diff --git a/scripts/test-rpm-repository.py b/scripts/test-rpm-repository.py index 750f9fb..24cf511 100644 --- a/scripts/test-rpm-repository.py +++ b/scripts/test-rpm-repository.py @@ -43,6 +43,8 @@ def log_message(self, *_args): class RepositoryTests(unittest.TestCase): @classmethod def setUpClass(cls): + if not shutil.which("dnf"): + raise unittest.SkipTest("Fedora repository cases require DNF") for tool in ( "createrepo_c", "dnf", "gpg", "gpgv", "openssl", "rpm", "rpmbuild", "rpmkeys", "rpmsign", ): @@ -455,5 +457,418 @@ def test_symlink_hardlink_and_output_reuse_rejected(self): ).returncode, 0) +class OpenSUSERepositoryTests(unittest.TestCase): + TARGET = "opensuse-tumbleweed-x86_64" + + @classmethod + def setUpClass(cls): + if not shutil.which("zypper"): + raise unittest.SkipTest("openSUSE repository cases require Zypper") + for tool in ( + "createrepo_c", "gpg", "gpgv", "openssl", "rpm", "rpmbuild", "rpmkeys", "rpmsign", "zypper", + ): + if not shutil.which(tool): + raise RuntimeError(f"{tool} required; run tests in Dockerfile.opensuse-rpm-tools") + cls.temporary = tempfile.TemporaryDirectory(prefix="loopwire-opensuse-repository-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 openSUSE Test", "Wrong openSUSE 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.tampered_key = cls.root / "tampered.asc" + cls.tampered_key.write_text(cls.key.read_text().replace("A", "B", 1)) + 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 source_revision(cls, version, suffix=""): + return hashlib.sha1(f"loopwire:{version}:{suffix}".encode(), usedforsecurity=False).hexdigest() + + @classmethod + def make_rpm( + cls, version, *, name="loopwire", release="1", architecture="x86_64", suffix="", + ): + fixture = cls.root / f"opensuse-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 openSUSE repository fixture\nLicense: MIT\n" + f"BuildArch: {architecture}\nAutoReqProv: no\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 openSUSE RPM fixture, got {packages}") + return packages[0] + + @classmethod + def write_release_manifest(cls, release, version, revision, artifacts=None): + if artifacts is None: + names = [f"loopwire-{version}-1.x86_64.rpm", "loopwire-linux-x86_64.tar.gz"] + classifications = { + names[0]: ("native-rpm", "opensuse-tumbleweed", "x86_64"), + names[1]: ("portable-archive", "linux-generic", "x86_64"), + } + artifacts = [{ + "name": name, + "kind": classifications[name][0], + "target": classifications[name][1], + "architecture": classifications[name][2], + "bytes": (release / name).stat().st_size, + "sha256": digest(release / name), + } for name in names] + manifest = { + "schema": "loopwire.release-assets.v1", + "release": {"tag": f"v{version}", "version": version, "gitHead": revision}, + "artifacts": artifacts, + } + (release / "release-assets.json").write_text(json.dumps(manifest, indent=2) + "\n") + + @classmethod + def sign_release(cls, release): + payloads = sorted( + path for path in release.iterdir() if path.name not in ("SHA256SUMS", "SHA256SUMS.sig") + ) + (release / "SHA256SUMS").write_text("".join( + f"{digest(payload)} {payload.name}\n" for payload in payloads + )) + run( + "openssl", "dgst", "-sha256", "-sign", cls.release_private, + "-out", release / "SHA256SUMS.sig", release / "SHA256SUMS", + ) + + @classmethod + def make_release( + cls, version, *, name="loopwire", release_tag="1", architecture="x86_64", suffix="", + ): + directory = cls.root / f"opensuse-release-{version}-{name}-{release_tag}-{architecture}{suffix}" + directory.mkdir() + built = cls.make_rpm( + version, name=name, release=release_tag, architecture=architecture, suffix=suffix, + ) + filename = f"loopwire-{version}-1.x86_64.rpm" + shutil.copyfile(built, directory / filename) + (directory / "loopwire-linux-x86_64.tar.gz").write_bytes( + f"portable release {version} {suffix}\n".encode() + ) + cls.write_release_manifest(directory, version, cls.source_revision(version, suffix)) + cls.sign_release(directory) + return directory + + @classmethod + def build(cls, release, version, output, *extra, ok=True): + return run( + sys.executable, SCRIPT, "build", "--target", cls.TARGET, + "--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 reset_repo(self): + shutil.rmtree(self.repo) + shutil.copytree(self.base, self.repo) + + def verify(self, *extra, ok=True, key=None, fingerprint=None, target=TARGET): + command = [ + sys.executable, SCRIPT, "verify", "--repository", self.repo, + "--public-key", key or self.key, "--fingerprint", fingerprint or self.fingerprint, + ] + if target is not None: + command.extend(["--target", target]) + return run(*command, *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) + manifest["revision"] = hashlib.sha256(json.dumps( + manifest, sort_keys=True, separators=(",", ":"), + ).encode()).hexdigest() + path.write_text(json.dumps(manifest)) + + def resign_plain_metadata(self): + repodata = self.repo / "repodata" + shutil.rmtree(repodata) + run( + "createrepo_c", "--quiet", "--no-database", "--checksum", "sha256", + "--repomd-checksum", "sha256", "--general-compress-type", "gz", + "--unique-md-filenames", self.repo, + ) + run( + "gpg", "--homedir", self.gnupg, "--batch", "--yes", "--armor", "--detach-sign", + "--local-user", self.fingerprint, "--output", repodata / "repomd.xml.asc", + repodata / "repomd.xml", + ) + + def zypper(self, *, install=True, key=None): + requests = [] + + class RecordingHandler(QuietHandler): + def do_GET(handler_self): + requests.append(handler_self.path.split("?", 1)[0]) + super().do_GET() + + handler = functools.partial(RecordingHandler, 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() + target_root = self.case_dir / f"zypper-{time.time_ns()}" + repos = target_root / "etc/zypp/repos.d" + keys = target_root / "etc/zypp/keys" + repos.mkdir(parents=True) + keys.mkdir(parents=True) + installed_key = keys / f"loopwire-repository-{self.fingerprint}.asc" + shutil.copyfile(key or self.key, installed_key) + (repos / "loopwire.repo").write_text( + "[loopwire]\nname=Loopwire openSUSE test\nenabled=1\nautorefresh=0\ntype=rpm-md\n" + f"baseurl=http://127.0.0.1:{server.server_port}/\n" + f"gpgkey={installed_key.resolve().as_uri()}\n" + "gpgcheck=1\nrepo_gpgcheck=1\npkg_gpgcheck=1\npriority=90\nkeeppackages=1\n" + ) + base = ["zypper", "--root", target_root, "--non-interactive", "--gpg-auto-import-keys"] + try: + refresh = run(*base, "refresh", "--force", "loopwire", ok=False) + transaction = None + if install and refresh.returncode == 0: + transaction = run( + *base, "--no-refresh", "install", "--no-recommends", "--from", "loopwire", + "loopwire", ok=False, + ) + return refresh, transaction, target_root, requests + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_signed_release_provenance_and_strict_zypper_install(self): + summary = json.loads(self.verify().stdout) + self.assertEqual(summary["target"], { + "distribution": "opensuse", "release": "tumbleweed", "architecture": "x86_64", + }) + package = summary["packages"][0] + source = self.release1 / "loopwire-1.0.0-1.x86_64.rpm" + self.assertEqual(package["sourceReleaseSha256"], digest(source)) + self.assertEqual(package["sourceRevision"], self.source_revision("1.0.0")) + self.assertNotEqual(package["distributedSha256"], digest(source)) + refresh, transaction, target_root, requests = self.zypper() + self.assertEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr) + self.assertIsNotNone(transaction) + self.assertEqual(transaction.returncode, 0, transaction.stdout + transaction.stderr) + self.assertEqual(run("rpm", "--root", target_root, "-q", "--queryformat", "%{EVR}", "loopwire").stdout, + "1.0.0-1") + self.assertIn("/repodata/repomd.xml.asc", requests) + self.assertNotIn("/repodata/repomd.xml.key", requests) + + def test_five_required_release_inputs_and_manifest_tampering_rejected(self): + self.assertEqual({path.name for path in self.release1.iterdir()}, { + "loopwire-1.0.0-1.x86_64.rpm", "loopwire-linux-x86_64.tar.gz", + "release-assets.json", "SHA256SUMS", "SHA256SUMS.sig", + }) + for missing in ("loopwire-linux-x86_64.tar.gz", "release-assets.json", "SHA256SUMS.sig"): + release = self.case_dir / f"missing-{missing.replace('.', '-')}" + shutil.copytree(self.release1, release) + (release / missing).unlink() + self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / f"out-{missing}", + ok=False).returncode, 0) + for filename in ( + "loopwire-1.0.0-1.x86_64.rpm", "loopwire-linux-x86_64.tar.gz", + "release-assets.json", "SHA256SUMS", "SHA256SUMS.sig", + ): + with self.subTest(tampered=filename): + release = self.case_dir / f"tampered-{filename.replace('.', '-')}" + shutil.copytree(self.release1, release) + with (release / filename).open("ab") as stream: + stream.write(b"tampered") + self.assertNotEqual(self.build( + release, "1.0.0", self.case_dir / f"tamper-out-{filename}", ok=False, + ).returncode, 0) + release = self.case_dir / "wrong-manifest" + shutil.copytree(self.release1, release) + manifest = json.loads((release / "release-assets.json").read_text()) + manifest["release"]["gitHead"] = "not-a-commit" + (release / "release-assets.json").write_text(json.dumps(manifest)) + self.sign_release(release) + self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / "wrong-commit", + ok=False).returncode, 0) + manifest["release"]["gitHead"] = self.source_revision("1.0.0") + manifest["artifacts"][0]["target"] = "fedora-44" + (release / "release-assets.json").write_text(json.dumps(manifest)) + self.sign_release(release) + self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / "wrong-target", + ok=False).returncode, 0) + + def test_identity_target_version_and_same_version_repack_rejected(self): + variants = [ + (dict(name="other"), "name"), + (dict(architecture="noarch"), "architecture"), + (dict(release_tag="2"), "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 / "wrong-version", + ok=False).returncode, 0) + self.assertNotEqual(self.verify("--target", "fedora-44-x86_64", target=None, + 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_deterministic_retention_downgrade_and_fresh_rollback(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()) + upgraded = self.case_dir / "upgraded" + self.build(self.release2, "1.1.0", upgraded, "--previous", self.base) + first = json.loads((self.base / "repository-manifest.json").read_text()) + latest = json.loads((upgraded / "repository-manifest.json").read_text()) + for entry in first["files"]: + if entry["kind"] == "immutable": + self.assertEqual(digest(upgraded / entry["path"]), entry["sha256"]) + self.assertEqual([package["version"] for package in latest["packages"]], ["1.0.0", "1.1.0"]) + self.assertNotEqual(self.build(self.release1, "1.0.0", self.case_dir / "downgrade", + "--previous", upgraded, ok=False).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"], first["packages"]) + self.assertEqual(rolled["target"], first["target"]) + self.assertNotEqual(rolled["revision"], first["revision"]) + run( + sys.executable, SCRIPT, "verify", "--repository", rollback, "--public-key", self.key, + "--fingerprint", self.fingerprint, "--target", self.TARGET, "--now", self.date + 60, + ) + + def test_zypper_rejects_wrong_or_tampered_key_and_metadata(self): + for key in (self.wrong_key, self.tampered_key): + refresh, _transaction, _root, _requests = self.zypper(install=False, key=key) + self.assertNotEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr) + repomd = self.repo / "repodata/repomd.xml" + repomd.write_text(repomd.read_text().replace("openSUSE Tumbleweed", "forged Tumbleweed")) + self.rewrite_manifest() + self.assertNotEqual(self.verify(ok=False).returncode, 0) + refresh, _transaction, _root, _requests = self.zypper(install=False) + self.assertNotEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr) + shutil.copytree(self.base, self.repo, dirs_exist_ok=True) + (self.repo / "repodata/repomd.xml.asc").unlink() + refresh, _transaction, _root, _requests = self.zypper(install=False) + self.assertNotEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr) + + def test_zypper_and_verifier_reject_unsigned_or_tampered_rpm(self): + 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) + self.resign_plain_metadata() + refresh, transaction, _root, _requests = self.zypper() + self.assertEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr) + self.assertIsNotNone(transaction) + self.assertNotEqual(transaction.returncode, 0, transaction.stdout + transaction.stderr) + self.reset_repo() + 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) + refresh, transaction, _root, _requests = self.zypper() + self.assertEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr) + self.assertIsNotNone(transaction) + self.assertNotEqual(transaction.returncode, 0, transaction.stdout + transaction.stderr) + self.reset_repo() + package = next(self.repo.glob("packages/*.rpm")) + run( + "rpmsign", "--resign", "--define", f"_gpg_name {self.wrong_fingerprint}", + "--define", f"_gpg_path {self.gnupg}", package, + ) + self.rewrite_manifest(update_packages=True) + self.assertNotEqual(self.verify(ok=False).returncode, 0) + self.resign_plain_metadata() + refresh, transaction, _root, _requests = self.zypper() + self.assertEqual(refresh.returncode, 0, refresh.stdout + refresh.stderr) + self.assertIsNotNone(transaction) + self.assertNotEqual(transaction.returncode, 0, transaction.stdout + transaction.stderr) + + def test_wrong_signer_expiry_and_repository_shape_rejected(self): + self.assertNotEqual(self.verify(key=self.wrong_key, ok=False).returncode, 0) + self.assertNotEqual(self.verify(fingerprint=self.wrong_fingerprint, ok=False).returncode, 0) + self.assertNotEqual(self.verify("--now", self.date + 31 * 86400, ok=False).returncode, 0) + (self.repo / "extra").write_text("unlisted") + self.assertNotEqual(self.verify(ok=False).returncode, 0) + (self.repo / "extra").unlink() + 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) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/scripts/verify-docs.sh b/scripts/verify-docs.sh index 298475c..b1327b2 100644 --- a/scripts/verify-docs.sh +++ b/scripts/verify-docs.sh @@ -8,6 +8,7 @@ required_files=( "apps/docs/docs/guide/install.md" "apps/docs/docs/guide/apt-repository.md" "apps/docs/docs/guide/fedora-repository.md" + "apps/docs/docs/guide/opensuse-repository.md" "apps/docs/docs/guide/basic-usage.md" "apps/docs/docs/guide/start-on-boot.md" "apps/docs/docs/guide/backends.md" @@ -21,6 +22,7 @@ required_files=( "apps/docs/docs/developer/release.md" "apps/docs/docs/developer/apt-repository.md" "apps/docs/docs/developer/fedora-repository.md" + "apps/docs/docs/developer/opensuse-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" @@ -71,6 +73,8 @@ 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/.vitepress/config.ts" "/guide/opensuse-repository" +assert_contains "apps/docs/docs/.vitepress/config.ts" "/developer/opensuse-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" @@ -79,6 +83,12 @@ 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/guide/opensuse-repository.md" "repo_gpgcheck=1" +assert_contains "apps/docs/docs/guide/opensuse-repository.md" "pkg_gpgcheck=1" +assert_contains "apps/docs/docs/guide/opensuse-repository.md" "sudo zypper install --oldpackage" +assert_contains "apps/docs/docs/developer/opensuse-repository.md" "OPENSUSE_REPOSITORY_ENABLED" +assert_contains "apps/docs/docs/developer/opensuse-repository.md" "A failed newer-snapshot run blocks activation" +assert_contains "apps/docs/docs/developer/opensuse-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-github-workflows.sh b/scripts/verify-github-workflows.sh index 51e2f6a..02dab6a 100755 --- a/scripts/verify-github-workflows.sh +++ b/scripts/verify-github-workflows.sh @@ -97,6 +97,7 @@ workflows=( ".github/workflows/ci.yml" ".github/workflows/publish-apt.yml" ".github/workflows/publish-fedora.yml" + ".github/workflows/publish-opensuse.yml" ".github/workflows/web.yml" ".github/workflows/aur.yml" ".github/workflows/workflow-checks.yml" @@ -129,6 +130,10 @@ assert_contains ".github/workflows/aur.yml" "scripts/verify-aur-source-package.s assert_contains ".github/workflows/aur.yml" "scripts/verify-aur-git-package.sh" assert_contains "package.json" '"verify:tauri": "bash scripts/verify-tauri.sh"' assert_contains "package.json" "pnpm verify:tauri" +assert_contains ".github/workflows/publish-opensuse.yml" "OPENSUSE_REPOSITORY_ENABLED" +assert_contains ".github/workflows/publish-opensuse.yml" "environment: packages-production" +assert_contains ".github/workflows/publish-opensuse.yml" "bash scripts/publish-opensuse-workflow.sh" +assert_contains ".github/workflows/release.yml" "publish-opensuse:" assert_contains ".github/workflows/continuous-tests.yml" "schedule:" assert_contains ".github/workflows/continuous-tests.yml" "scripts/ct-host-check.sh" @@ -339,6 +344,7 @@ 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" +ruby "$root/scripts/test-opensuse-workflow.rb" node "$root/scripts/test-native-package-proof-snapshot.mjs" echo "GitHub workflow contract verification passed." diff --git a/scripts/verify-opensuse-public.py b/scripts/verify-opensuse-public.py new file mode 100755 index 0000000..77df662 --- /dev/null +++ b/scripts/verify-opensuse-public.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +"""Verify a served openSUSE 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 + + +TARGET = {"distribution": "opensuse", "release": "tumbleweed", "architecture": "x86_64"} +TARGET_SELECTOR = "opensuse-tumbleweed-x86_64" + + +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", + "--target", TARGET_SELECTOR, "--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") != TARGET: + raise ValueError("candidate does not target openSUSE Tumbleweed 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-openSUSE-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": "opensuse-tumbleweed", + "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-opensuse-public: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/verify-opensuse-repository-vm-proof.mjs b/scripts/verify-opensuse-repository-vm-proof.mjs new file mode 100755 index 0000000..ea24707 --- /dev/null +++ b/scripts/verify-opensuse-repository-vm-proof.mjs @@ -0,0 +1,390 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { lstat, readFile, readdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + parseInstalledHashes, + parsePayloadRelease, + parseReleaseChecksums, + rpmPayload, + verifyReleaseSignature, + verifyRpmSignature, +} from "./verify-fedora-repository-vm-proof.mjs"; + +const repositoryRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const repositoryTarget = "opensuse-tumbleweed-x86_64"; +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 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"); } +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\.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; +} + +export function targetManifestRow(value, target) { + const rows = value.split("\n") + .filter((line) => line && !line.startsWith("#")) + .map((line) => line.split("\t")) + .filter((row) => row[0] === target); + requireThat(rows.length === 1, "target missing or duplicate in image manifest"); + requireThat(rows[0].length === 9 && rows[0].every((field) => field.length > 0), + "target image manifest row must contain nine nonempty fields"); + return rows[0]; +} + +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 opensuse = manifest.artifacts.filter((entry) => entry && entry.target === "opensuse-tumbleweed"); + equal(opensuse.length, 1, "openSUSE release artifact count"); + assert.deepEqual(Object.keys(opensuse[0]).sort(), ["architecture", "bytes", "kind", "name", "sha256", "target"], + "openSUSE release artifact fields"); + assert.deepEqual(opensuse[0], { name: expected.rpmName, kind: "native-rpm", target: "opensuse-tumbleweed", + architecture: "x86_64", bytes: expected.rpmBytes, sha256: expected.rpmSha256 }, "openSUSE 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 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`); + assert.deepEqual(Object.keys(entry).sort(), ["architecture", "distributedSha256", "name", "path", "release", "size", + "sourceReleaseSha256", "sourceRevision", "version"], `${label} package entry fields`); + equal(entry.name, "loopwire", `${label} package name`); + equal(entry.version, expected.version, `${label} package version`); + equal(entry.release, "1", `${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.sourceRevision, expected.sourceRevision, `${label} public source revision`); + equal(entry.distributedSha256, packageSha256, `${label} distributed RPM hash`); + requireThat(Number.isSafeInteger(entry.size) && entry.size > 0, `${label} package size missing`); +} + +function xmlAttributes(value) { + const result = {}; + for (const match of value.matchAll(/([A-Za-z][A-Za-z0-9_-]*)="([^"]*)"/g)) result[match[1]] = match[2]; + return result; +} + +export function verifyZypperSearch(value, version) { + const records = [...value.matchAll(/]*)\/>/g)].map((match) => xmlAttributes(match[1])) + .filter((entry) => entry.name === "loopwire" && entry.edition === version); + equal(records.length, 1, "Zypper repository candidate result count"); + for (const [key, expected] of Object.entries({ name: "loopwire", edition: version, arch: "x86_64", + repository: "Loopwire for openSUSE Tumbleweed - x86_64" })) { + equal(records[0][key], expected, `Zypper ${key}`); + } +} + +export function verifyZypperInstalledSearch(value, version) { + const records = [...value.matchAll(/]*)\/>/g)].map((match) => xmlAttributes(match[1])) + .filter((entry) => entry.name === "loopwire"); + equal(records.length, 1, "Zypper installed package result count"); + for (const [key, expected] of Object.entries({ status: "installed", name: "loopwire", edition: version, + arch: "x86_64" })) equal(records[0][key], expected, `installed Zypper ${key}`); + requireThat(["Loopwire for openSUSE Tumbleweed - x86_64", "(System Packages)"].includes(records[0].repository), + "installed Zypper repository is neither the active candidate nor the native system view"); +} + +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\t(none)`, `${stage} package metadata/vendor`); + equal((await text(directory, `${prefix}zypper-origin.tsv`)).trim(), + `loopwire\t${version}\tx86_64\tLoopwire for openSUSE Tumbleweed - x86_64\t(none)`, + `${stage} repository origin/vendor`); + verifyZypperInstalledSearch(await text(directory, `${prefix}zypper-search.xml`), version); + verifyZypperSearch(await text(directory, `${prefix}zypper-repository-search.xml`), version); + const info = await text(directory, `${prefix}zypper-info.txt`); + requireThat(/^Name\s*:\s*loopwire$/m.test(info) && + new RegExp(`^Version\\s*:\\s*${version.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "m").test(info), + `${stage} Zypper package info lacks repository/version`); + const files = (await text(directory, `${prefix}package-files.txt`)).trim().split("\n"); + for (const installed of requiredPaths) requireThat(files.includes(installed), `${stage} missing ${installed}`); + assert.deepEqual(parseInstalledHashes(await text(directory, `${prefix}installed-files.sha256`)), 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`); + requireThat(!/error while loading shared libraries|panic|protocol error|missing acquire timeline/i.test( + await text(directory, `${prefix}gui-launch.log`, false)), `${stage} fatal GUI log`); + await text(directory, `${prefix}xvfb.log`, false); +} + +export async function verifyEvidence({ target, evidenceDir, gitHead, targetManifest }) { + equal(target, "opensuse-tumbleweed", "supported target"); + requireThat(/^[a-f0-9]{40}$/.test(gitHead ?? ""), "--git-head must be a full lowercase commit hash"); + requireThat(typeof targetManifest === "string" && targetManifest, "target manifest is required"); + const summary = tsvMap(await text(evidenceDir, "summary.tsv"), "summary"); + equal(summary.get("schema"), "loopwire.opensuse-repository-vm-proof.v1", "schema"); + equal(summary.get("target"), target, "target"); + const testedSnapshot = summary.get("snapshot"); + requireThat(/^[0-9]{8}$/.test(testedSnapshot ?? ""), "tested Tumbleweed snapshot must be YYYYMMDD"); + 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("+") ? "." : "+"}zypperfixture1`; + equal(summary.get("upgrade_version"), upgradedVersion, "synthetic fixture version"); + const baselineVersion = `${version}-1`; + const upgradeVersion = `${upgradedVersion}-1`; + 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:8445/opensuse/tumbleweed/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"), "opensuse-tumbleweed", "guest OS"); + equal(os.get("VERSION_ID"), testedSnapshot, "guest snapshot"); + 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 row = targetManifestRow(await readFile(targetManifest, "utf8"), target); + 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 publicRpmName = `loopwire-${baselineVersion}.x86_64.rpm`; + assert.deepEqual((await readdir(publicDirectory)).sort(), ["RELEASE", "SHA256SUMS", "SHA256SUMS.sig", publicRpmName, + "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 checksums = parseReleaseChecksums(await text(publicDirectory, "SHA256SUMS")); + 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(checksums.has(name), `signed checksums lack ${name}`); + equal(checksums.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]", "type=rpm-md", `baseurl=${baseUrl}`, "enabled=1", "autorefresh=1", "priority=99", + "gpgcheck=1", "repo_gpgcheck=1", "pkg_gpgcheck=1", + `gpgkey=file:///etc/zypp/keys/loopwire-repository-${fingerprint}.asc`]) { + requireThat(source.split("\n").includes(line), `Zypper source lacks ${line}`); + } + requireThat(!/gpgcheck\s*=\s*0|repo_gpgcheck\s*=\s*0|pkg_gpgcheck\s*=\s*0|ssl_?verify\s*=\s*(?:0|no|false)|keeppackages\s*=\s*1/i.test(source), + "Zypper proof bypasses authentication or retains packages unexpectedly"); + 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: publicRpmName, 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`); + const summaryPrefix = stage === "upgraded" ? "upgrade" : stage; + equal(summary.get(`${summaryPrefix}_source_sha256`), sources.get(stage).sha256, `${stage} summary source hash`); + equal(summary.get(`${summaryPrefix}_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`); + } + assert.deepEqual((await readdir(path.join(evidenceDir, "packages"))).sort(), ["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-opensuse-rpm-tools.sh"), "--read-only-path", evidenceDir, + "python3", path.join(repositoryRoot, "scripts/rpm-repository.py"), "verify", "--target", repositoryTarget, + "--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: "opensuse", release: "tumbleweed", 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$/, ""), name: expectedNames[fixtureStage], + sourceRevision: releaseManifest.release.gitHead }, + 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 Zypper operation/version log`); + requireThat(log.includes( + `Retrieving: loopwire-${expectedVersion}.x86_64 (Loopwire for openSUSE Tumbleweed - x86_64)`), + `${stage} operation log lacks authenticated repository origin`); + } + const commands = await text(evidenceDir, "commands.log"); + for (const needle of ["zypper --non-interactive install --from loopwire", "--force --no-allow-vendor-change --no-allow-arch-change", + "zypper --non-interactive update --repo loopwire loopwire", "--oldpackage --force", + "zypper --non-interactive remove loopwire", " -Kv ", "smoke_installed", "xdotool"]) { + requireThat(commands.includes(needle), `missing executed command: ${needle}`); + } + for (const file of ["bootstrap.log", "bootstrap-refresh.log", "upgrade-refresh.log", "rollback-refresh.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 [`/opensuse/tumbleweed/x86_64/keys/${fingerprint}.asc`, + "/opensuse/tumbleweed/x86_64/repodata/repomd.xml", "/opensuse/tumbleweed/x86_64/repodata/repomd.xml.asc", + `/opensuse/tumbleweed/x86_64/packages/${expectedNames.baseline}`, + `/opensuse/tumbleweed/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 Zypper source remains active"); + return { target, snapshot: testedSnapshot, gitHead, baselineVersion, upgradeVersion, fingerprint }; +} + +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", "--target-manifest"].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 targetManifest = path.resolve(args["--target-manifest"] ?? + path.join(repositoryRoot, "packaging/vm/native-package-targets.tsv")); + const result = await verifyEvidence({ target: args["--target"], evidenceDir: path.resolve(args["--evidence-dir"]), + gitHead: args["--git-head"], targetManifest }); + console.log(`openSUSE repository VM proof verified: ${result.target} snapshot ${result.snapshot}`); + console.log(JSON.stringify(result)); + } catch (error) { + console.error(`verify-opensuse-repository-vm-proof: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/verify-opensuse-repository.sh b/scripts/verify-opensuse-repository.sh new file mode 100755 index 0000000..97fe7f0 --- /dev/null +++ b/scripts/verify-opensuse-repository.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [ "${1:-}" != --inside ]; then + exec bash "$root/scripts/with-opensuse-rpm-tools.sh" --container \ + bash "$root/scripts/verify-opensuse-repository.sh" --inside +fi +cd "$root" +export PYTHONDONTWRITEBYTECODE=1 +bash -n scripts/setup-opensuse-repository.sh scripts/publish-opensuse-workflow.sh \ + scripts/with-opensuse-rpm-tools.sh packaging/vm/guest-opensuse-repository-smoke.sh +shellcheck scripts/setup-opensuse-repository.sh scripts/publish-opensuse-workflow.sh \ + scripts/with-opensuse-rpm-tools.sh packaging/vm/guest-opensuse-repository-smoke.sh \ + scripts/verify-opensuse-repository.sh +node --check scripts/verify-opensuse-repository-vm-proof.mjs +node --check scripts/test-opensuse-repository-vm-proof.mjs +for script in scripts/rpm-repository.py scripts/publish-rpm-repository.py \ + scripts/verify-opensuse-public.py scripts/test-opensuse-bootstrap.py scripts/test-opensuse-public.py \ + scripts/test-opensuse-workflow-preflight.py; do + python3 - "$script" <<'PY' +import ast +import pathlib +import sys +path = pathlib.Path(sys.argv[1]) +ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) +PY +done +python3 scripts/test-rpm-repository.py +python3 scripts/test-publish-rpm-repository.py --with-ssh +python3 scripts/test-opensuse-bootstrap.py +python3 scripts/test-opensuse-public.py +python3 scripts/test-opensuse-workflow-preflight.py +ruby scripts/test-opensuse-workflow.rb +node scripts/test-opensuse-repository-vm-proof.mjs +node --test apps/site/src/lib/opensuseChannel.test.mjs +echo 'openSUSE repository development verification passed.' diff --git a/scripts/verify-requirements.sh b/scripts/verify-requirements.sh index dbe75a9..176e953 100644 --- a/scripts/verify-requirements.sh +++ b/scripts/verify-requirements.sh @@ -124,9 +124,10 @@ 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:rpm-repository" + "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 && pnpm verify:opensuse-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:opensuse-repository" "bash scripts/verify-opensuse-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-repository.sh b/scripts/verify-rpm-repository.sh index 9dec45d..153c105 100755 --- a/scripts/verify-rpm-repository.sh +++ b/scripts/verify-rpm-repository.sh @@ -17,4 +17,5 @@ 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 +node --test apps/site/src/lib/opensuseChannel.test.mjs echo 'Fedora repository development verification passed.' diff --git a/scripts/verify-scripts.sh b/scripts/verify-scripts.sh index 270f81d..cd148eb 100755 --- a/scripts/verify-scripts.sh +++ b/scripts/verify-scripts.sh @@ -56,8 +56,13 @@ bash -n \ scripts/build-native-packages.sh \ scripts/build-portable-linux-binary.sh \ scripts/native-package-vm.sh \ + scripts/setup-opensuse-repository.sh \ + scripts/publish-opensuse-workflow.sh \ + scripts/with-opensuse-rpm-tools.sh \ + scripts/verify-opensuse-repository.sh \ scripts/promote-native-package-vm-proof.sh \ packaging/vm/guest-native-package-smoke.sh \ + packaging/vm/guest-opensuse-repository-smoke.sh \ scripts/verify-requirements.sh \ scripts/verify-docs.sh @@ -106,6 +111,9 @@ 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 +node --check scripts/verify-opensuse-repository-vm-proof.mjs +node --check scripts/test-opensuse-repository-vm-proof.mjs +node scripts/test-opensuse-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 diff --git a/scripts/with-opensuse-rpm-tools.sh b/scripts/with-opensuse-rpm-tools.sh new file mode 100755 index 0000000..a758c37 --- /dev/null +++ b/scripts/with-opensuse-rpm-tools.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +image="${LOOPWIRE_OPENSUSE_RPM_TOOLS_IMAGE:-loopwire-rpm-tools:opensuse-tumbleweed}" +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-opensuse-rpm-tools.sh [--container] [--read-only-path PATH] COMMAND [ARG ...]' >&2 + exit 2 +} +available=true +for command in createrepo_c gpg gpgv openssl python3 rpm rpmkeys rpmsign zypper; 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 'openSUSE repository tools or Docker are required; see the openSUSE repository guide.' >&2 + exit 1 +} +if ! docker image inspect "$image" >/dev/null 2>&1; then + docker build --file "$root/packaging/repositories/Dockerfile.opensuse-rpm-tools" --tag "$image" "$root" >&2 +fi +exec docker run --rm --network none --env PYTHONDONTWRITEBYTECODE=1 \ + --volume "$root:$root:ro" "${mounts[@]}" --workdir "$root" "$image" "$@"