diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed5efb2..b81bbf8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,7 @@ on: - "tsconfig.base.json" - ".npmrc" - "!**/*.md" + - "!packaging/repositories/*-channel.json" - "!scripts/*docs*" - "!scripts/build-static-site.mjs" - "!scripts/verify-static-site.mjs" @@ -46,6 +47,7 @@ on: - "tsconfig.base.json" - ".npmrc" - "!**/*.md" + - "!packaging/repositories/*-channel.json" - "!scripts/*docs*" - "!scripts/build-static-site.mjs" - "!scripts/verify-static-site.mjs" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index d80adff..c0e942f 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -11,6 +11,7 @@ on: paths: - ".github/workflows/deploy-docs.yml" - "apps/site/**" + - "packaging/repositories/apt-channel.json" - "apps/docs/**" - "assets/product-screenshot.png" - "package.json" diff --git a/.github/workflows/publish-apt.yml b/.github/workflows/publish-apt.yml new file mode 100644 index 0000000..d495abb --- /dev/null +++ b/.github/workflows/publish-apt.yml @@ -0,0 +1,86 @@ +name: Publish APT 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: "37 5 * * 1" + +permissions: + contents: read + +concurrency: + group: apt-repository-production + cancel-in-progress: false + +jobs: + publish: + if: >- + ${{ + vars.APT_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: 30 + environment: packages-production + steps: + - name: Checkout publisher + uses: actions/checkout@v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Node for release verification + uses: actions/setup-node@v6.4.0 + with: + node-version: 22.23.1 + + - name: Install repository tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends apt-utils dpkg-dev gnupg gpgv openssh-client python3 + + - name: Build, publish, and verify repository + env: + GH_TOKEN: ${{ github.token }} + OPERATION: ${{ inputs.operation || 'refresh' }} + RELEASE_TAG: ${{ inputs.tag }} + ROLLBACK_REVISION: ${{ inputs.revision }} + APT_REPOSITORY_URL: ${{ vars.APT_REPOSITORY_URL }} + APT_REPOSITORY_HOST: ${{ vars.APT_REPOSITORY_HOST }} + APT_REPOSITORY_ROOT: ${{ vars.APT_REPOSITORY_ROOT }} + APT_SSH_PORT: ${{ vars.APT_SSH_PORT || '22' }} + APT_SIGNING_FINGERPRINT: ${{ vars.APT_SIGNING_FINGERPRINT }} + APT_SSH_PRIVATE_KEY: ${{ secrets.APT_SSH_PRIVATE_KEY }} + APT_SSH_KNOWN_HOSTS: ${{ secrets.APT_SSH_KNOWN_HOSTS }} + APT_SIGNING_KEY: ${{ secrets.APT_SIGNING_KEY }} + APT_SIGNING_PASSPHRASE: ${{ secrets.APT_SIGNING_PASSPHRASE }} + run: bash scripts/publish-apt-workflow.sh + + - name: Upload public verification and activation record + uses: actions/upload-artifact@v7.0.1 + with: + name: loopwire-apt-publication-${{ github.run_id }} + path: dist/apt-publication + if-no-files-found: error + retention-days: 90 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d2aea31..fd12162 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -207,6 +207,8 @@ jobs: needs: build-linux runs-on: ubuntu-22.04 timeout-minutes: 45 + outputs: + tag: ${{ steps.verified-tag.outputs.tag }} steps: - name: Checkout uses: actions/checkout@v7.0.0 @@ -497,3 +499,17 @@ jobs: ${{ env.LOOPWIRE_RELEASE_EVIDENCE_ARCHIVE }} if-no-files-found: error retention-days: 90 + + - name: Export verified release tag + id: verified-tag + run: printf 'tag=%s\n' "$LOOPWIRE_RELEASE_TAG" >>"$GITHUB_OUTPUT" + + publish-apt: + name: Publish signed APT channel + needs: publish-release + if: ${{ vars.APT_REPOSITORY_ENABLED == 'true' && !contains(needs.publish-release.outputs.tag, '-') }} + uses: ./.github/workflows/publish-apt.yml + with: + tag: ${{ needs.publish-release.outputs.tag }} + operation: publish + secrets: inherit diff --git a/.github/workflows/web.yml b/.github/workflows/web.yml index 69684d9..8e80ef8 100644 --- a/.github/workflows/web.yml +++ b/.github/workflows/web.yml @@ -5,6 +5,7 @@ on: paths: - ".github/workflows/web.yml" - "apps/site/**" + - "packaging/repositories/apt-channel.json" - "apps/docs/**" - "assets/product-screenshot.png" - "README.md" @@ -30,6 +31,7 @@ on: paths: - ".github/workflows/web.yml" - "apps/site/**" + - "packaging/repositories/apt-channel.json" - "apps/docs/**" - "assets/product-screenshot.png" - "README.md" diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml index f013a00..a6b5667 100644 --- a/.github/workflows/workflow-checks.yml +++ b/.github/workflows/workflow-checks.yml @@ -7,6 +7,7 @@ on: - "scripts/ci-impact.rb" - "scripts/test-ci-impact.rb" - "scripts/test-ci-workflow-paths.rb" + - "scripts/test-apt-workflow.rb" - "scripts/*native-package-proof-snapshot.mjs" - "scripts/verify-github-workflows.sh" - "scripts/verify-requirements.sh" @@ -21,6 +22,7 @@ on: - "scripts/ci-impact.rb" - "scripts/test-ci-impact.rb" - "scripts/test-ci-workflow-paths.rb" + - "scripts/test-apt-workflow.rb" - "scripts/*native-package-proof-snapshot.mjs" - "scripts/verify-github-workflows.sh" - "scripts/verify-requirements.sh" diff --git a/.gitignore b/.gitignore index 7f86e37..741ba06 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules/ dist/ dist-ssr/ coverage/ +__pycache__/ .vitepress/cache/ .vitepress/dist/ .astro/ diff --git a/.planning/STATE.md b/.planning/STATE.md index b822c41..5466819 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -3,7 +3,7 @@ gsd_state_version: 1.0 milestone: v0.5 milestone_name: GitHub Operator Setup status: Ready for Review -last_updated: "2026-09-05T11:55:11.036Z" +last_updated: "2026-09-05T14:08:23Z" last_activity: 2026-09-05 progress: total_phases: 1 @@ -27,7 +27,7 @@ See: .planning/PROJECT.md (updated 2026-07-03) Phase: 19 of 19 complete Plan: 19.1 — Hardened GitHub Actions Setup Status: Ready for review in PR #9 -Last activity: 2026-09-05 - completed quick task 260905-i4l: CI input scopes and full release-validation evidence +Last activity: 2026-09-05 - completed quick task 260905-kyo: signed APT repository development and clean-guest proof ## Blockers / Concerns @@ -93,6 +93,7 @@ Last activity: 2026-09-05 - completed quick task 260905-i4l: CI input scopes and | 260905-fld | Default platform installer and homepage tabs; native install/reinstall proof | 2026-09-05 | c415386 | [260905-fld-homepage-platform-installer](./quick/260905-fld-homepage-platform-installer/) | | 260905-hia | Minimal landing identity, GSAP reactions and screenshot proofs in PR #40 | 2026-09-05 | 6e84a9a | [260905-hia-landing-identity](./quick/260905-hia-landing-identity/) | | 260905-i4l | Scope CI to affected files while retaining release validation | 2026-09-05 | f9bc0d8 | [260905-i4l-scope-ci](./quick/260905-i4l-scope-ci/) | +| 260905-kyo | Signed APT repository development and clean-guest lifecycle proof | 2026-09-05 | 639cbbb | [260905-kyo-signed-apt](./quick/260905-kyo-signed-apt/) | ## Accumulated Context diff --git a/.planning/quick/260905-kyo-signed-apt/260905-kyo-PLAN.md b/.planning/quick/260905-kyo-signed-apt/260905-kyo-PLAN.md new file mode 100644 index 0000000..cf63523 --- /dev/null +++ b/.planning/quick/260905-kyo-signed-apt/260905-kyo-PLAN.md @@ -0,0 +1,50 @@ +--- +status: implementing +issue: 35 +--- + +# Signed APT repository development + +Goal: complete the development checklist in #35, verify it, and open one dedicated PR containing `resolves #35`. +The overall goal continues with #36 and then #37 after this PR is opened. Production accounts, keys, credentials, +and the first public activation remain the separately listed human operational work. + +## Contract and decisions + +- Target Ubuntu 24.04 and Debian 13, amd64, using the existing tested native package payload and recipes. +- Reuse existing Python/Bash/Git/OpenSSH tooling and distro APT/GnuPG utilities; no new application dependencies. +- Project-owned HTTPS publication uses a POSIX server over SSH. This provides a verifiable same-filesystem atomic + InRelease replacement; Bunny's documented PUT interface does not establish the required publication guarantee. +- Suites are ubuntu-24.04 and debian-13, component main. Preserve immutable pool and by-hash URLs indefinitely in + the first implementation. InRelease is the per-suite commit point; no cross-suite instantaneous transaction claim. +- Keep the server HTTP document root separate from private publication state and retained snapshots. Serialize + writes, compare the expected current revision, reject immutable collisions, and recover interrupted promotion. +- OpenPGP repository signatures are independent of the existing OpenSSL release checksum signatures. Verify both. +- Metadata is valid for 30 days; provide protected scheduled refresh of the same package set and explicit rollback + that produces fresh signed metadata. Rollback requires an explicit package downgrade on already upgraded clients. +- Homepage repository commands activate only through validated channel configuration after human public proof; + until then retain the functional existing installation options. Implement and test the activated UI in fixtures. + +## Tasks and ownership + +1. Generator and trust verification (`apt_protocol`): scripts/apt-repository.py and tests; Packages/Release/InRelease, + exact release-package validation, immutable inventory, prior-version retention, version ordering, fresh rollback, + tamper/path/key/architecture failure tests using real signing and APT tools. +2. Publication (`apt_publisher`): scripts/publish-package-repository.py and tests; local and SSH transports, locking, + compare-and-swap, immutable snapshots, atomic per-suite promotion/recovery, dry-run and cache configuration. +3. Lifecycle (`apt_guest_surface`): explicit APT mode in the existing VM runner, guest lifecycle script and independent + evidence verifier; fresh matching guests install/reinstall/upgrade/rollback/remove through HTTPS APT and perform + real GUI/provider/linkage checks. Preserve historical native proof; label synthetic package revisions as fixtures. +4. Integration (root): scoped bootstrap, release/refresh/rollback workflows, CI inputs, configuration contract, + gated homepage/install documentation, regression coverage, review, final validation and PR delivery. + +## Required evidence + +- Every development checkbox in #35 maps to implemented files and an executed check in the summary. +- Repository signatures and actual APT reject wrong keys, altered metadata/packages and incomplete publication. +- Retry, concurrent publication, downgrade and rollback rules are exercised, including actual SSH transport. +- Clean Ubuntu and Debian KVM guests consume the served HTTPS repository, with version/origin/signature and real + installed GUI/provider checks recorded for all applicable lifecycle transitions. +- Workflow syntax/contract checks, docs/build checks, focused tests and the full local validation pass. +- PR targets the current default branch, has a reviewable diff and `resolves #35`, and explicitly lists the human + production setup/activation still required. Do not claim public production installation was verified without it. diff --git a/.planning/quick/260905-kyo-signed-apt/260905-kyo-SUMMARY.md b/.planning/quick/260905-kyo-signed-apt/260905-kyo-SUMMARY.md new file mode 100644 index 0000000..261415a --- /dev/null +++ b/.planning/quick/260905-kyo-signed-apt/260905-kyo-SUMMARY.md @@ -0,0 +1,83 @@ +--- +status: complete +issue: 35 +--- + +# Signed APT repository development + +Issue: https://github.com/sandwichfarm/loopwire/issues/35 + +## Result + +The development work for the Ubuntu 24.04 and Debian 13 amd64 APT channel is complete. The checked-in channel remains +`pending` until the separately listed human operations provision a production HTTPS/SSH origin, create the signing +identity, configure the protected environment, and perform the first public verification. Existing homepage install +commands remain usable; a complete reviewed activation record switches only the Ubuntu and Debian panels to +`sudo apt install loopwire` and exposes the repository-scoped bootstrap command. + +## Development checklist evidence + +1. **Layout/configuration:** `apt-repository.py` defines separate `ubuntu-24.04` and `debian-13` suites under + `main/binary-amd64`, suite-specific pool paths, retained SHA-256 by-hash indexes, stable dpkg version ordering, + 30-day signed metadata, indefinite v1 immutable retention, and a strict manifest. The operator runbook specifies + every variable, secret, path, permission, cache, monitoring, and key-rotation boundary. +2. **Generation:** build verifies the existing OpenSSL-signed release manifest and exact internal deb identity before + preserving those package bytes. It creates Packages/Packages.gz, Release, OpenPGP clear-signed InRelease, exported + fingerprint key, by-hash objects, and the independently validated inventory. Verify checks the entire trust chain. +3. **Publication/rollback:** the local/SSH publisher validates before writes, requires pinned host trust, locks the + POSIX origin, uses revision compare-and-swap, rejects immutable collisions, retains private snapshots, promotes + immutable objects before metadata, and atomically replaces each suite's InRelease. Durable journals resume every + interruption point; expired recovery is explicit and demands immediate refresh. Rollback re-signs selected + package sets with fresh dates. The Nginx example serves only `ROOT/public`, revalidates metadata and long-caches + immutable URLs. +4. **Protected automation:** Publish APT Repository supports release-triggered publish, operator publish/refresh/ + rollback, and weekly expiry refresh. `APT_REPOSITORY_ENABLED=true` and `packages-production` gate writes. Stable + release publication waits for the existing GitHub Release/evidence gates, re-downloads and verifies public release + assets, then verifies every HTTPS-served byte before producing a reviewable activation record. Preflight rejects + unsafe configuration before key or origin access. +5. **Bootstrap:** the repeat-safe helper supports Ubuntu 24.04 and Debian 13 amd64, downloads only HTTPS key material, + pins the full fingerprint, uses `/etc/apt/keyrings` and a deb822 source with `Signed-By`, preserves unrelated + sources, rejects symlink escapes, supports no-network dry-run and safe removal, and never uses `apt-key` or insecure + APT options. User docs cover install, updates, repair, explicit downgrade, source removal, trust and key changes. +6. **Regression tests:** the dedicated suite runs real GPG/OpenSSL/dpkg/APT checks plus an actual disposable SSH + server. It rejects unsigned or tampered metadata, modified packages, wrong signers, bad suite/package identity, + downgrades outside explicit rollback, unsafe files, stale CAS, concurrent access and origin drift. It exercises 22 + publisher cases, all resumable checkpoints, permissions under umask 077, expired-journal recovery, public HTTPS + tampering, bootstrap containment and a real Zstandard deb on the pinned Debian 13 toolchain. +7. **Matching guests:** clean checksum-pinned Ubuntu 24.04 and Debian 13 KVM guests installed from a guest-only HTTPS + repository using the real scoped bootstrap. Each performed install, reinstall, a synthetic `+aptfixture1` upgrade, + explicit downgrade/rollback, removal and source removal. The verifier binds repository origin, versions, signed + package hashes, every installed `/usr` file, providers/backend detector, GUI linkage and a real X11 application + window. The synthetic version reuses the authenticated v0.1.0 payload and is lifecycle evidence, not a release. +8. **Docs/UI:** homepage, install guide, support matrix, release guide, user APT guide, maintainer runbook, release + notes, and navigation are updated. Pending and verified-fixture browser tests prove the fallback and activated + states. Production activation remains the human step that supplies verified public values; Loopwire is never + described as part of a distribution's default repository. + +## Verification + +- `pnpm check` passed after the final review change: all project verification, types, 295 workspace tests, 22 Rust + tests, builds, static-site validation and the dedicated APT suite. +- `pnpm verify:apt` passed: 13 generator, 22 publisher (including actual SSH), 11 bootstrap, 9 public HTTPS, 5 workflow + preflight, 17 proof-verifier, and 4 homepage channel cases. +- Generator tests passed with real APT on both Debian 13 and Ubuntu 24.04; wrong-key, unsigned/tampered metadata and + modified-package downloads were rejected. +- Ubuntu and Debian lifecycle proof directories each contain 96 evidence files from commit `7849a1b`, revalidated + successfully with the final portable verifier at `639cbbb`. +- Pending production build browser tests passed all existing install/copy/keyboard/responsive/motion/fallback cases. + The verified fixture passed those same checks and additionally rendered both short APT commands, their separate + setup links, and the complete URL/fingerprint setup command. The checked-in record was restored to pending. +- Workflow contracts, actionlint, ShellCheck, Python/Node/Ruby/Bash syntax, docs/build checks and whitespace checks + passed. Final review's Zstandard portability finding was reproduced, fixed with `dpkg-deb --fsys-tarfile`, covered + by a real compressed package, and approved on re-review. + +## Human operations still open + +No production host/account, TLS certificate, SSH credential, OpenPGP identity, GitHub environment value, or public +repository was created or changed. No production publication was triggered. The five Human operational tasks in +issue #35 remain unchecked. The first public run must attach the public URL/fingerprint/revision and clean-client +evidence, then its reviewed `apt-channel.json` can activate the website through a separate commit. + +Power-loss behavior and network filesystems were not tested; the publication contract explicitly requires local +POSIX filesystem locking/fsync/atomic-rename semantics. Ubuntu/Debian guests used a disposable local CA and repository +key; fixture trust cannot generate a production activation record. diff --git a/apps/docs/docs/.vitepress/config.ts b/apps/docs/docs/.vitepress/config.ts index 4048e27..afd70bc 100644 --- a/apps/docs/docs/.vitepress/config.ts +++ b/apps/docs/docs/.vitepress/config.ts @@ -43,6 +43,7 @@ export default defineConfig({ text: "Guide", items: [ { text: "Install", link: "/guide/install" }, + { text: "APT Repository", link: "/guide/apt-repository" }, { text: "Basic Usage", link: "/guide/basic-usage" }, { text: "Configurations", link: "/guide/configurations" }, { text: "Audio Backends", link: "/guide/backends" }, @@ -60,6 +61,7 @@ export default defineConfig({ { text: "GitHub Actions Setup", link: "/developer/github-actions-setup" }, { text: "VM Matrix", link: "/developer/vm-matrix" }, { text: "Release", link: "/developer/release" }, + { text: "APT Repository Operations", link: "/developer/apt-repository" }, { text: "Release Notes", link: "/developer/release-notes" } ] }, diff --git a/apps/docs/docs/developer/apt-repository.md b/apps/docs/docs/developer/apt-repository.md new file mode 100644 index 0000000..14ea678 --- /dev/null +++ b/apps/docs/docs/developer/apt-repository.md @@ -0,0 +1,254 @@ +# Signed APT repository operations + +This runbook is for maintainers preparing, publishing, or recovering Loopwire's APT channel. Development of the +channel is separate from public activation. The checked-in channel record starts `pending`: provisioning the server, +signing identity, protected GitHub environment, and first public publication remain human operations. Keep the +existing direct-download instructions working until those operations and public verification are complete. + +## Scope and trust boundary + +The channel serves `main`/`amd64` in two independent suites: `ubuntu-24.04` and `debian-13`. It packages the existing +native release payload; no UI or audio-backend behavior belongs in this layer. Stable `X.Y.Z` versions, optionally +with `+build` metadata, are accepted. Prereleases and cross-distro substitutions are rejected. + +Two signatures have different jobs: + +1. The existing project **OpenSSL release key** authenticates `SHA256SUMS.sig` over `SHA256SUMS`. The generator checks + the exact Ubuntu and Debian artifacts against that manifest and their internal package metadata before indexing. +2. A separate **OpenPGP APT key** signs the clear-signed `InRelease`. APT authenticates metadata and follows its SHA-256 + hashes through `Packages` to each deb. Clients trust that key only for the Loopwire source through `Signed-By`. + +The web root contains public packages, indexes, signed release metadata, and public keys. Private signing material, +publication state, retained snapshots, locks, and transaction staging must never be served over HTTP. + +## Human provisioning + +Provision a project-owned HTTPS host with a valid public certificate and an SSH account restricted to the repository +storage root. The host needs Python 3 and POSIX filesystem semantics; GnuPG runs on the publishing runner. Put staging +and the public root on the same filesystem so rename is atomic. Serve **`ROOT/public` only**, disable directory +listing, and deny sibling `ROOT/snapshots` and publication state. Back up private snapshots and state independently. + +The SSH approach is deliberate: the existing Bunny PUT upload surface does not establish an atomic replacement +contract for repository metadata. The ordinary website deployment remains separate. + +Configure HTTP caching so clients cannot receive a new index behind stale release metadata: + +- `InRelease`, `Release`, and ordinary `Packages`/compressed indexes: `Cache-Control: no-store` or + equivalent mandatory revalidation. +- Content-addressed `by-hash` indexes and immutable package pool paths: long cache lifetime with `immutable`. +- Public keys and the current manifest: revalidate. Do not cache failures for paths that will soon be published. + +Create a dedicated OpenPGP signing identity on a trusted machine. Record its complete 40-character fingerprint, +expiration, custodian, backup, and revocation-certificate location. Keep the offline recovery copy and revocation +certificate separate from CI secrets. Do not reuse the OpenSSL release key. Export an ASCII-armored public key for +verification and a private export solely for the protected publishing environment. + +Configure the GitHub environment **`packages-production`**, restrict its permitted branches/tags to reviewed release +inputs, and apply its human approval policy. The workflow validates required configuration before remote writes. + +| Kind | Name | Purpose | +| --- | --- | --- | +| Repository variable | `APT_REPOSITORY_ENABLED` | Set to `true` to call APT publication after a successful tagged GitHub Release publication. Leave disabled until provisioned. | +| Variable | `APT_REPOSITORY_URL` | Canonical public HTTPS URL, without credentials, query, or fragment. | +| Variable | `APT_REPOSITORY_HOST` | SSH `USER@HOST` for the publication account. | +| Variable | `APT_REPOSITORY_ROOT` | Absolute private storage root; HTTP serves its `public` child. | +| Variable | `APT_SIGNING_FINGERPRINT` | Full uppercase OpenPGP fingerprint. | +| Optional variable | `APT_SSH_PORT` | SSH port when not 22. | +| Secret | `APT_SSH_PRIVATE_KEY` | Restricted SSH identity for publication. | +| Secret | `APT_SSH_KNOWN_HOSTS` | Host-key pins obtained through a trusted channel. | +| Secret | `APT_SIGNING_KEY` | Dedicated ASCII-armored OpenPGP private signing key. | +| Optional secret | `APT_SIGNING_PASSPHRASE` | Passphrase for that private export. | + +The enabled variable gates manual and scheduled runs as well as release-triggered publication. Leave it disabled +until all production inputs are ready; enabling it permits those workflows to write the repository. It does not +activate the public website's install commands. + +Do not generate trusted SSH pins from an unauthenticated scan in the publishing job. Configure monitoring for failed +publication/refresh runs, certificate expiry, signing-key expiry, and metadata approaching its 30-day expiry. + +## Build and verify a candidate + +Use a reviewed checkout and a directory containing published stable GitHub Release artifacts, `SHA256SUMS`, and +`SHA256SUMS.sig`. Set `APT_FPR` to the full signing fingerprint and `APT_GNUPG_HOME` to a protected GnuPG home containing +that key. The following paths are operator-chosen local staging directories; never expose the GnuPG home to HTTP. +Local generation needs Python 3, `dpkg-deb`, `dpkg`, GnuPG (`gpg` and `gpgv`), and OpenSSL. The protected Ubuntu runner +provides these tools; other development hosts can use the pinned APT tools container described below. + +```bash +python3 scripts/apt-repository.py build \ + --release-dir dist/release --version 0.1.0 --output dist/apt-candidate \ + --signing-key "$APT_FPR" --gnupg-home "$APT_GNUPG_HOME" +python3 scripts/apt-repository.py verify \ + --repository dist/apt-candidate --public-key apt-public.asc --fingerprint "$APT_FPR" +``` + +The default release verifier uses `packaging/release-signing-public.pem`; `--release-public-key FILE` selects an +explicit alternative for isolated fixtures. Production must retain the project trust anchor. Use `--previous DIR` +when advancing an existing repository so old pool objects and by-hash indexes remain available. Supply +`--passphrase-file FILE` when the signing key needs one; keep it private and delete temporary key material after use. + +`--date EPOCH` and `--valid-for-days 30` control signed timestamps. Fixed dates are for reproducible fixtures; normal +publication uses fresh dates. The verifier accepts `--now EPOCH` for expiry tests. Never publish already expired +metadata or turn off client expiry checks. + +## Publish, refresh, and recover + +Prefer the protected **Publish APT Repository** workflow. It supports manual `publish`, `refresh`, and `rollback`, +and weekly refresh. Tagged release publication calls it only after the GitHub Release has been published and only +when `APT_REPOSITORY_ENABLED=true`. It downloads and checks published release inputs instead of trusting an arbitrary +local build directory. An APT failure does not undo the existing GitHub Release; repair it and retry the APT job. +For a manual run, use the default branch: choose `publish` with an existing stable `tag`, `refresh` with no release +input, or `rollback` with the retained 64-character `revision`. The weekly run selects refresh automatically. + +For a reviewed local rehearsal, the same publisher can operate without SSH. For production, supply all SSH identity +and host-key arguments. In these examples `APT_ROOT`, `APT_HOST`, and `APT_REVISION` name the provisioned root, host, and +the current manifest revision. Set `APT_REVISION=empty` for an initial publication only. + +```bash +python3 scripts/publish-package-repository.py fetch \ + --root "$APT_ROOT" --output dist/apt-previous \ + --public-key apt-public.asc --fingerprint "$APT_FPR" \ + --ssh "$APT_HOST" --identity-file apt-ssh-key --known-hosts apt-known-hosts +python3 scripts/publish-package-repository.py publish \ + --repository dist/apt-candidate --root "$APT_ROOT" \ + --public-key apt-public.asc --fingerprint "$APT_FPR" --expected-revision "$APT_REVISION" \ + --ssh "$APT_HOST" --identity-file apt-ssh-key --known-hosts apt-known-hosts --dry-run +``` + +Read the dry-run result, then repeat publication without `--dry-run`. Add `--ssh-port PORT` if needed. Initial +publication skips `fetch`; subsequent publication builds with the fetched repository as `--previous`. The expected +revision provides compare-and-swap protection: a conflicting publisher must refetch and rebuild, not force a stale +candidate over the current channel. A lock serializes publication on the server. +Take `APT_REVISION` from the verified `fetch` JSON result. Fetching an empty root exits with code 3; a pending +transaction blocks fetch until recovery. Private state files are diagnostics, not a replacement for verified fetch. +Fetch verifies snapshots at their original signed creation time so expired but authentic snapshots remain usable for +refresh and rollback. Successful fetch alone does not prove current client usability; publication and public +verification also require metadata that is valid now. + +Immutable package and by-hash objects are installed first; existing paths with different content are rejected. +Each suite's `InRelease` rename is its commit point. Clients must see either the previous complete suite or the next +complete suite. Ubuntu and Debian may transition at different instants; there is no cross-suite atomicity claim. +Interrupted promotion retains recovery state. Recover using the pending candidate's key before retrying a failed +publication, then inspect the current revision and verify served metadata: + +```bash +python3 scripts/publish-package-repository.py recover \ + --root "$APT_ROOT" --public-key apt-public.asc --fingerprint "$APT_FPR" \ + --ssh "$APT_HOST" --identity-file apt-ssh-key --known-hosts apt-known-hosts +``` + +Recovery verifies the pending snapshot at the current time before resuming its exact journal. If a transaction has +remained pending beyond metadata expiry, inspect it with `recover --allow-expired --dry-run`. The explicit +`--allow-expired` recovery option validates its signature and hash chain at its original signed creation time, +finishes only that journal, and returns `requiresRefresh: true`. Use it only for an operator-reviewed expired +transaction, then immediately fetch, re-sign, and publish fresh metadata. Clients still reject expired metadata; +the option does not disable APT expiry checks. The workflow does not apply this override automatically. Do not change +the clock or edit public files and transaction state by hand to bypass a conflict. + +Refresh republishes the same package set with a new signed date and 30-day validity. It does not rebuild the +application or manufacture a new application release. Run it manually after missed schedules and verify both suites. +GitHub schedules can be delayed or disabled, so the weekly job is not the expiry monitor. + +## Retention and rollback + +Version 1 retains immutable package pool paths, by-hash indexes, and publication snapshots indefinitely. This keeps +old signed metadata and rollback references resolvable. There is no automatic garbage collection. Monitor storage +growth; a future deletion policy needs an explicit design covering metadata validity, cache lifetime, active clients, +and recovery retention before any objects are removed. + +Select a known-good snapshot revision and fetch it using `fetch --revision SHA`. Rollback signs that snapshot's +package set with **fresh** metadata; copying old expired `InRelease` files is not a valid rollback: + +```bash +python3 scripts/apt-repository.py rollback \ + --repository dist/apt-known-good --output dist/apt-rollback \ + --signing-key "$APT_FPR" --gnupg-home "$APT_GNUPG_HOME" +python3 scripts/apt-repository.py verify \ + --repository dist/apt-rollback --public-key apt-public.asc --fingerprint "$APT_FPR" +``` + +Publish the rollback candidate against the **current** revision, then run public verification. Previously installed +newer packages remain installed: clients must explicitly choose the distro-specific version with +`sudo apt-get install --allow-downgrades loopwire=VERSION`. Communicate that command and the reason for rollback; +the [user guide](../guide/apt-repository.md#earlier-versions) explains the client steps. + +## Signing-key rotation and revocation + +Treat a routine key change as a coordinated release operation. Generate the successor identity offline, publish its +full fingerprint through trusted project channels, and retain both keys' public material and historical snapshots. +Clients need the updated scoped keyring **before** they accept metadata signed only by the successor. The bootstrap +helper can replace its managed source with the newly verified key; ordinary package upgrades do not silently change +the trust anchor. Test the transition on both clean and existing-client guests, including rollback, before production. + +The conservative version 1 rotation path uses a **new repository root and HTTPS prefix**. Build a fresh candidate +from authenticated release files under the successor key without `--previous`, publish and verify that prefix, then +update the protected environment and client setup to the new URL/fingerprint. Leave the old prefix and key available +for the announced migration window if the old key remains trustworthy. Existing clients rerun the helper to replace +their source. A fetched old snapshot still requires its old key; `build --previous` and `rollback` accept snapshots +signed by their current signer, so they cannot silently re-sign old-key history as a new trust identity. + +The fingerprint-named public key file is immutable too: changing its expiry or subkeys changes its bytes and cannot +overwrite that URL in place. Use the reviewed successor-key procedure. Publishing itself can accept a new signer, +but that does not establish client trust or migrate old snapshots. The current single-key bootstrap provides no +unattended cross-signing or automatic rotation. Keep the public record `pending` during any unverified transition, +then review a new public verification record before reactivating instructions. + +If the key is compromised, disable publication and refresh immediately, remove its private export from CI, publish +the revocation certificate and an incident notice through trusted channels, and take the affected channel out of +service. A revocation certificate alone does not update existing local keyrings. Direct clients to remove the old +managed source/keyring, inspect the announced replacement fingerprint, and bootstrap the replacement explicitly. +Verify package provenance independently before republishing with a new key; re-signing compromised content does not +repair it. Preserve private evidence and recover only from known-good snapshots or authenticated release inputs. + +## Public verification and final activation + +Local signing tests and isolated VM fixtures establish development behavior. They do not prove that users can reach +the production repository. After initial publication, verify the actual HTTPS-served bytes against the candidate: + +```bash +python3 scripts/verify-apt-public.py \ + --repository dist/apt-candidate --public-key apt-public.asc --fingerprint "$APT_FPR" \ + --base-url "$APT_URL" --proof-url "$APT_PROOF_URL" --output dist/apt-channel.json +``` + +`APT_URL` is the canonical production URL and `APT_PROOF_URL` is that successful GitHub Actions run's URL. The checker +validates the local signed chain, fetches every manifest file over HTTPS without redirects, compares exact hashes and +sizes, and emits a verified record only on success. `--ca-file FILE` is for isolated HTTPS test CAs; it is not public +production proof. Do not create a production activation record from a fixture server or a synthetic package upgrade. + +The workflow uploads `loopwire-apt-publication-RUN_ID` with `apt-channel.json`, `publication.json`, and +`repository-manifest.json`. **Final activation is a human operation:** inspect the successful run, review the URL, +signing fingerprint, revision, timestamp, and package versions, complete initial public clean-client installation +checks, then copy its `apt-channel.json` into `packaging/repositories/apt-channel.json` in a reviewed commit. Build and +deploy the website from that commit. Do not hand-edit `status` alone or place operator credentials in this file. + +The schema is version 1. A pending record has null URL, fingerprint, revision, verification timestamp, and proof URL. +A verified record needs an HTTPS base URL, 40 uppercase hex fingerprint characters, a 64 lowercase hex revision, +an ISO timestamp, and the project GitHub Actions run URL. The homepage and user setup page validate every field; +missing or invalid data retains manual installation commands. On activation, Ubuntu and Debian tabs show +`sudo apt install loopwire` and link separately to one-time setup. Automatic, other distributions, and direct +download instructions remain available. + +## Development evidence + +Run generator, bootstrap, publisher, public-checker, and channel-gating tests, plus workflow, docs, and site checks. +For example, `bash scripts/with-apt-tools.sh --container python3 scripts/test-apt-repository.py` runs signing and real +APT checks inside the pinned Debian 13 tools image. The wrapper mounts the repository read-only and keeps test +temporary writes inside the container, without requiring a developer's host distro to install APT. It does not +replace the separate clean-guest lifecycle runs or their real GUI/provider evidence. +The package lifecycle harness has dedicated modes: + +```bash +pnpm vm:native-packages -- run-apt --target ubuntu-24.04 --version 0.1.0 --release-dir dist/release +pnpm vm:native-packages -- run-apt --target debian-13 --version 0.1.0 --release-dir dist/release +pnpm vm:native-packages -- verify-apt --target ubuntu-24.04 +pnpm vm:native-packages -- verify-apt --target debian-13 +``` + +These boot fresh checksum-pinned distro guests, use HTTPS APT with scoped trust, and exercise package installation, +reinstall, upgrade, downgrade/rollback, and removal. The container regression suite separately proves rejection of +untrusted or broken repository state. Record exact +versions, source URLs, key fingerprint, candidate selection, signature results, GUI launch, and provider-command +checks. A synthetic `+aptfixture1` upgrade reuses the authenticated `0.1.0` payload to test lifecycle behavior; label +it as fixture evidence, never as a newly released application or public production proof. A package lifecycle pass +does not promote desktop-session or live audio-backend support claims. diff --git a/apps/docs/docs/developer/release.md b/apps/docs/docs/developer/release.md index 50e5f18..dbbba88 100644 --- a/apps/docs/docs/developer/release.md +++ b/apps/docs/docs/developer/release.md @@ -3,6 +3,20 @@ Loopwire releases are artifact-first. Every install channel must consume the same tarballs, `SHA256SUMS`, and `SHA256SUMS.sig`. +## Signed APT channel + +Ubuntu 24.04 and Debian 13 package publication has a separate [APT operations runbook](./apt-repository.md). It covers +the independent OpenPGP trust anchor, required protected environment and SSH/HTTPS hosting, release publication, +weekly metadata refresh, retention, rollback, key changes, and client removal. The optional **Publish APT Repository** +workflow runs after GitHub Release publication when `APT_REPOSITORY_ENABLED=true`; an APT failure leaves the existing +GitHub Release intact for repair and retry. + +Development and public activation are separate gates. Provisioning production infrastructure and signing keys, +running initial public verification, and reviewing the emitted channel record remain human operations. Until the +verified record is committed and the site deployed, Ubuntu and Debian homepage tabs retain signed direct-download +commands. Fixture lifecycle tests do not count as production proof. Follow the runbook's final activation procedure +before announcing repository availability. + ## Local Artifact Smoke ```bash diff --git a/apps/docs/docs/guide/apt-repository.md b/apps/docs/docs/guide/apt-repository.md new file mode 100644 index 0000000..a386c53 --- /dev/null +++ b/apps/docs/docs/guide/apt-repository.md @@ -0,0 +1,122 @@ + + +# APT repository + +Loopwire's APT channel targets **Ubuntu 24.04 and Debian 13 on x86_64 (`amd64`)**. It is a project repository that +requires one-time setup; Loopwire is not included in either distribution's default repositories. Other releases, +derivatives, and ARM64 should use the matching options in the [installation guide](./install.md). + +
+

Public channel pending

+

The repository tooling is available in the source tree. A public repository URL and signing key have not been activated. + Use the Ubuntu or Debian signed direct + downloads, or the automatic installer. The setup command will appear here after public verification.

+
+ +
+

Verified public channel

+

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

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

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

+
{{ setupCommand }}
+
+ +The helper detects the exact distribution version and architecture, downloads the repository's OpenPGP key over +HTTPS, and checks its full fingerprint before making changes. It writes only the Loopwire source and scoped +keyring. An existing unrelated source with the same filename is an error; other APT sources are preserved. +Add `--dry-run` and omit `sudo` to preview the selected suite and paths without downloads or changes. + +After successful setup, refresh package metadata and install: + +```bash +sudo apt update && +sudo apt install loopwire +``` + +The setup helper does not run either command for you. Confirm `apt update` succeeds for the Loopwire source before +installing. `apt-cache policy loopwire` shows the candidate version and repository URL; the URL should match the +verified channel above. The package includes the desktop application and background/provider commands, without +enabling startup services or applying audio routes during installation. + +## Updates and reinstall + +APT can update Loopwire alongside your other packages. To update only Loopwire: + +```bash +sudo apt update && +sudo apt install --only-upgrade loopwire +``` + +To repair package-owned files at the installed version, first find the exact version with +`dpkg-query -W -f='${Version}\n' loopwire`, then run `sudo apt install --reinstall loopwire=VERSION` with that value. +Saved routing configurations are outside package ownership and are preserved. + +## Earlier versions + +Use `apt-cache policy loopwire` to inspect available versions. Repository rollback changes the recommended package +set, but APT will not automatically downgrade a newer installed version. If a maintainer recommends rollback, +replace `VERSION` with the exact distro-specific version and opt into it: + +```bash +sudo apt update && +sudo apt-get install --allow-downgrades loopwire=VERSION +``` + +Keep the distro suffix: an Ubuntu package is not interchangeable with the Debian package. Older downloads are +retained for recovery; the active package index determines which versions APT can select. Ask for recovery guidance +if the required version is absent, rather than bypassing package authentication. + +## Remove Loopwire or the repository + +Uninstall the application with `sudo apt remove loopwire`. APT removes package-owned files and leaves your saved +configuration intact. Remove any startup integration you explicitly enabled using the +[start-on-boot guide](./start-on-boot.md). + +To stop receiving Loopwire repository updates, use the same inspected helper: + +```bash +sudo bash setup-apt-repository.sh --remove && +sudo apt update +``` + +This removes the managed `loopwire.sources` file and its referenced Loopwire keyring. It does not uninstall Loopwire +or affect other repositories. Manually provisioned source files require manual removal of the matching source and +keyring. Do not remove shared distro keyrings. + +## Trust and troubleshooting + +APT checks signed repository metadata, which binds package indexes and their package checksums. The repository +OpenPGP key is separate from the OpenSSL key used to verify direct GitHub Release downloads. The setup uses a +per-source `Signed-By` keyring; it does not grant Loopwire's key authority over other repositories. + +- **Wrong or changed fingerprint:** stop and compare this page's fingerprint with the project's announced key + change. Rerun the inspected setup helper with the new full fingerprint only after that change is confirmed. +- **Expired metadata:** check the system clock, retry `sudo apt update`, and report the error if it persists. + Repository metadata expires after 30 days and should be refreshed by the project before then. +- **Invalid signature, checksum mismatch, or missing file:** stop the install, retry metadata refresh, and report + the failing URL and APT error. Do not disable authentication, TLS validation, or expiry checks. +- **Unsupported distribution or architecture:** use the [installation guide](./install.md). Changing a suite name + to force a different distro package does not make that package compatible. + +Diagnostics need the distro version, architecture, `apt-cache policy loopwire`, and the APT error text. Redact local +usernames and private URLs; never include audio recordings or private keys. diff --git a/apps/docs/docs/guide/install.md b/apps/docs/docs/guide/install.md index b58f93a..2c39325 100644 --- a/apps/docs/docs/guide/install.md +++ b/apps/docs/docs/guide/install.md @@ -19,8 +19,8 @@ installer changes an existing installation. | Platform | Default installation path | | --- | --- | -| Ubuntu 24.04, x86_64 | Signed release deb through APT | -| Debian 13, x86_64 | Signed release deb through APT | +| Ubuntu 24.04, x86_64 | Signed direct-download deb installed by APT | +| Debian 13, x86_64 | Signed direct-download deb installed by APT | | Fedora 44, x86_64 | Signed release RPM through DNF | | openSUSE Tumbleweed, x86_64 | Signed release RPM through Zypper | | Arch Linux, x86_64 or ARM64 | `loopwire-bin` through an existing yay or paru; portable fallback without a helper | @@ -81,6 +81,17 @@ Automatic performs the download and verification steps for you. For manual insta below together in an empty directory. Commands are connected with `&&` so failed downloads or checks stop the install. These are direct `v0.1.0` downloads, not distro repositories. Use Automatic to select the latest available release. +The [APT repository guide](./apt-repository.md) shows channel availability, the verified URL and key when activated, +and one-time setup for Ubuntu 24.04 and Debian 13 on x86_64. After that setup and a successful `sudo apt update`, the +repository install command is: + +```bash +sudo apt install loopwire +``` + +Loopwire is not in the default Ubuntu or Debian repositories. Until the guide displays a verified public channel, +use Automatic or the signed manual downloads below. The automatic installer does not configure the APT repository. + The RPM files have no embedded RPM signature. The commands authenticate the download using the signed SHA-256 manifest first, then permit this local RPM for that install; repository dependency checks remain enabled. @@ -98,8 +109,7 @@ sha256sum --check --ignore-missing SHA256SUMS && sudo apt install ./loopwire_0.1.0-1ubuntu24.04_amd64.deb ``` -[Repository work to shorten this setup](https://github.com/sandwichfarm/loopwire/issues/35) tracks signed metadata, -release publication, clean-guest install/upgrade verification, and updated instructions. +[APT repository setup and availability](./apt-repository.md) includes upgrade, rollback, removal, and key guidance. ### Debian 13 @@ -115,8 +125,7 @@ sha256sum --check --ignore-missing SHA256SUMS && sudo apt install ./loopwire_0.1.0-1debian13_amd64.deb ``` -[Repository work to shorten this setup](https://github.com/sandwichfarm/loopwire/issues/35) tracks signed metadata, -release publication, clean-guest install/upgrade verification, and updated instructions. +[APT repository setup and availability](./apt-repository.md) includes upgrade, rollback, removal, and key guidance. ### Fedora 44 @@ -263,8 +272,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. They are not -yet served through an APT, DNF/COPR, or OBS repository. Their matching-guest proof command boots official, +The AppImages and native deb/RPM files are published as direct downloads on the `v0.1.0` GitHub Release. The APT +repository has a separate [public activation gate](./apt-repository.md); DNF/COPR and OBS repositories remain planned. +Their matching-guest proof command boots official, checksum-pinned cloud images under KVM and stores local evidence without changing host audio: ```bash diff --git a/apps/docs/docs/guide/support-matrix.md b/apps/docs/docs/guide/support-matrix.md index 254a9ac..28d489d 100644 --- a/apps/docs/docs/guide/support-matrix.md +++ b/apps/docs/docs/guide/support-matrix.md @@ -70,7 +70,8 @@ the Tauri shell command bridge. | Source checkout | `pnpm check` | Supported for contributors. | | Signed curl installer | Local verification plus live `/install.sh` byte comparison | Published for 0.1.0 at `loopwire.app`. | | AppImage | Published-artifact and Tauri bundle smoke | Published for 0.1.0 on GitHub Releases. | -| Ubuntu 24.04 / Debian 13 deb | Verified in matching KVM guests at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as direct downloads; no APT repository. | +| Ubuntu 24.04 / Debian 13 deb | Verified in matching KVM guests at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as direct downloads. | +| Ubuntu 24.04 / Debian 13 signed APT repository, amd64 | Separate signed-metadata, publication, bootstrap, and clean-guest lifecycle checks | Public activation is gated; see [current channel availability](./apt-repository.md). | | Fedora 44 / openSUSE Tumbleweed RPM | Verified in matching KVM guests at commit `70eee4e`; review snapshot in `vm/native-package-proof/` | Published as direct downloads; no COPR/OBS repository. | | 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. | @@ -81,6 +82,11 @@ The flake package output is `packages..loopwire-bin`; `flake.nix` now pi for `x86_64-linux` and `aarch64-linux`. Fresh local non-skipped Nix build evidence in this repository currently covers `x86_64-linux` only; `aarch64-linux` still needs native proof. +APT lifecycle evidence is separate from the direct-download snapshot. Isolated HTTPS guests and synthetic +`+aptfixture1` package upgrades demonstrate development behavior with the existing release payload; they do not +establish a new public release or production channel. Only reviewed production HTTPS verification activates APT +instructions. Repository support covers the named distro versions on amd64, not derivatives or other architectures. + 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 8b32292..dcc19d4 100644 --- a/apps/docs/docs/release-notes/unreleased.md +++ b/apps/docs/docs/release-notes/unreleased.md @@ -2,6 +2,22 @@ These notes describe source-tree progress. They are not a public release announcement. +## Signed APT repository development + +- Added signed APT metadata generation and verification for Ubuntu 24.04 and Debian 13 on amd64, consuming the + existing authenticated native release artifacts with a separate OpenPGP repository key. +- Added guarded SSH publication, retained snapshots and immutable package/index paths, fresh-metadata rollback, + protected release publication, and weekly metadata refresh. +- Added scoped repository bootstrap/removal and dedicated clean-guest APT lifecycle verification. Synthetic + `+aptfixture1` upgrades test the existing `0.1.0` payload; they are development evidence, not a new application release. +- Ubuntu and Debian homepage tabs switch to the short APT command only after a complete public verification record + is reviewed and committed. Production hosting, key/environment provisioning, and first public activation remain + separate human operations. Existing automatic and signed direct-download installation paths remain available. +- Added [user setup and recovery guidance](../guide/apt-repository.md) and the + [maintainer publication runbook](../developer/apt-repository.md). + +## Other distribution updates + - Root-level Bunny deployments now pass the post-upload live-site check when `BUNNY_REMOTE_PREFIX` is intentionally empty, while an omitted command argument remains an error. - The signed installer is now live at `https://loopwire.app/install.sh`; AppImage, deb, and RPM artifacts are available diff --git a/apps/site/src/lib/aptChannel.mjs b/apps/site/src/lib/aptChannel.mjs new file mode 100644 index 0000000..2788726 --- /dev/null +++ b/apps/site/src/lib/aptChannel.mjs @@ -0,0 +1,63 @@ +/** + * A public channel is advertised only after its complete HTTPS verification record + * has been reviewed and committed. Invalid or incomplete records keep manual installs. + * @param {unknown} value + */ +export function verifiedAptChannel(value) { + if (!value || typeof value !== "object") return null; + const channel = /** @type {Record} */ (value); + if (channel.schemaVersion !== 1 || channel.status !== "verified" || + typeof channel.baseUrl !== "string" || !validBaseUrl(channel.baseUrl) || + typeof channel.signingFingerprint !== "string" || !/^[A-F0-9]{40}$/.test(channel.signingFingerprint) || + typeof channel.revision !== "string" || !/^[a-f0-9]{64}$/.test(channel.revision) || + typeof channel.verifiedAt !== "string" || !validTimestamp(channel.verifiedAt) || + typeof channel.proofUrl !== "string" || + !/^https:\/\/github\.com\/sandwichfarm\/loopwire\/actions\/runs\/[1-9][0-9]*$/.test(channel.proofUrl)) { + return null; + } + return { + baseUrl: channel.baseUrl.replace(/\/+$/, ""), + signingFingerprint: channel.signingFingerprint, + revision: channel.revision, + verifiedAt: channel.verifiedAt, + proofUrl: channel.proofUrl + }; +} + +/** @param {string} value */ +function validBaseUrl(value) { + try { + const url = new URL(value); + return value.startsWith("https://") && !/[^\x21-\x7e]|[\\'"`$<>?#]/.test(value) && + url.protocol === "https:" && Boolean(url.hostname) && !url.username && !url.password && + (!url.port || Number(url.port) >= 1) && !url.search && !url.hash; + } catch { + return false; + } +} + +/** @param {string} value */ +function validTimestamp(value) { + if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) || + !Number.isFinite(Date.parse(value))) return false; + const date = value.slice(0, 10); + return new Date(`${date}T00:00:00Z`).toISOString().slice(0, 10) === date; +} + +/** + * @template {{command: string, note: string, detail: string, href: string, link: string}} T + * @param {unknown} channel + * @param {T} manual + * @returns {T} + */ +export function aptInstallOption(channel, manual) { + if (!verifiedAptChannel(channel)) return manual; + return { + ...manual, + command: "sudo apt install loopwire", + note: "After one-time setup, install and update Loopwire through its signed APT repository.", + detail: "Ubuntu 24.04 and Debian 13 on x86_64 are supported. Other versions and ARM64 use the portable path.", + href: "/docs/guide/apt-repository.html#one-time-setup", + link: "Set up the APT repository" + }; +} diff --git a/apps/site/src/lib/aptChannel.test.mjs b/apps/site/src/lib/aptChannel.test.mjs new file mode 100644 index 0000000..e0829c3 --- /dev/null +++ b/apps/site/src/lib/aptChannel.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { aptInstallOption, verifiedAptChannel } from "./aptChannel.mjs"; + +const verified = { + schemaVersion: 1, + status: "verified", + baseUrl: "https://packages.example.test/loopwire/", + 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: "ubuntu", + command: "sudo apt install ./loopwire_0.1.0-1ubuntu24.04_amd64.deb", + note: "Verify the signed download first.", + detail: "Other versions use portable installation.", + href: "/docs/guide/apt-repository.html", + link: "APT repository setup and availability" +}; + +test("pending and incomplete channel records preserve the functional manual option", () => { + for (const value of [null, undefined, {}, [], { ...verified, status: "pending" }]) { + assert.equal(verifiedAptChannel(value), null); + assert.equal(aptInstallOption(value, manual), manual); + } + for (const key of Object.keys(verified)) { + const value = { ...verified }; + delete value[key]; + assert.equal(verifiedAptChannel(value), null, `missing ${key}`); + assert.equal(aptInstallOption(value, manual), manual); + } +}); + +test("verified channel exposes an install command and separate one-time setup link", () => { + assert.equal(verifiedAptChannel(verified).baseUrl, "https://packages.example.test/loopwire"); + for (const id of ["ubuntu", "debian"]) { + const option = aptInstallOption(verified, { ...manual, id }); + assert.equal(option.id, id); + assert.equal(option.command, "sudo apt install loopwire"); + assert.equal(option.href, "/docs/guide/apt-repository.html#one-time-setup"); + assert.doesNotMatch(`${option.note} ${option.detail}`, /workflow|revision|activation|operator/); + } + assert.match(manual.command, /\.deb$/); +}); + +test("malformed proof records never activate the channel", () => { + const invalid = { + schemaVersion: [0, "1"], status: [true, "ready"], + baseUrl: ["http://packages.example.test", "https://user:password@packages.example.test", "https://packages.example.test?a=1", + "https://packages.example.test#fragment", " https://packages.example.test", "https://packages.example.test/\n", "not a URL", + "https://packages.example.test:0", "https://packages.example.test:65536", + "https://packages.example.test?", "https://packages.example.test#", + "https://packages.example.test/$(id)", "https://packages.example.test/`id`", "https://packages.example.test/\\wrong"], + signingFingerprint: ["a".repeat(40), "A".repeat(39), "G".repeat(40)], + revision: ["A".repeat(64), "a".repeat(63)], + verifiedAt: ["yesterday", "2026-09-05", "2026-02-30T00:00:00Z", "2026-09-05T25:00:00Z"], + proofUrl: ["https://github.com/other/repo/actions/runs/123", "https://github.com/sandwichfarm/loopwire/pull/35", + "https://github.com/sandwichfarm/loopwire/actions/runs/123?fixture=1", "https://github.com/sandwichfarm/loopwire/actions/runs/0"] + }; + for (const [key, values] of Object.entries(invalid)) { + for (const value of values) { + const record = { ...verified, [key]: value }; + assert.equal(verifiedAptChannel(record), null, `${key}: ${value}`); + assert.equal(aptInstallOption(record, manual), manual); + } + } +}); + +test("checked-in channel either remains pending or has a complete verification record", () => { + const channel = JSON.parse(readFileSync(new URL("../../../../packaging/repositories/apt-channel.json", import.meta.url), "utf8")); + if (channel.status === "pending") { + assert.deepEqual(channel, { schemaVersion: 1, status: "pending", baseUrl: null, + signingFingerprint: null, revision: null, verifiedAt: null, proofUrl: null }); + } else { + assert.ok(verifiedAptChannel(channel)); + } +}); diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 0dcbc56..ebaee03 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -1,6 +1,8 @@ --- import SiteLayout from "../layouts/SiteLayout.astro"; import screenshot from "../../../../assets/product-screenshot.png"; +import aptChannel from "../../../../packaging/repositories/apt-channel.json"; +import { aptInstallOption } from "../lib/aptChannel.mjs"; const title = "Loopwire | Linux virtual audio routing"; const description = @@ -43,22 +45,22 @@ const installOptions = [ detail: "No AUR helper? Use Automatic for a portable install. The loopwire, loopwire-bin, and loopwire-git packages conflict; choose one.", href: "https://aur.archlinux.org/packages/loopwire-bin", link: "View AUR package" }, - { + aptInstallOption(aptChannel, { id: "ubuntu", label: "Ubuntu", heading: "Ubuntu 24.04 · x86_64", command: nativeInstall("loopwire_0.1.0-1ubuntu24.04_amd64.deb", "sudo apt install ./loopwire_0.1.0-1ubuntu24.04_amd64.deb"), note: "Manual signed v0.1.0 package. Run these steps together in an empty folder; a failed check stops the " + "install. Automatic handles these steps for you.", - detail: "Other Ubuntu versions and ARM64 use the portable path. A native APT repository is planned to shorten this setup.", - href: "https://github.com/sandwichfarm/loopwire/issues/35", link: "Track simpler APT installs" - }, - { + detail: "Other Ubuntu versions and ARM64 use the portable path.", + href: "/docs/guide/apt-repository.html", link: "APT repository setup and availability" + }), + aptInstallOption(aptChannel, { id: "debian", label: "Debian", heading: "Debian 13 · x86_64", command: nativeInstall("loopwire_0.1.0-1debian13_amd64.deb", "sudo apt install ./loopwire_0.1.0-1debian13_amd64.deb"), note: "Manual signed v0.1.0 package. Run these steps together in an empty folder; a failed check stops the " + "install. Automatic handles these steps for you.", - detail: "Other Debian versions and ARM64 use the portable path. A native APT repository is planned to shorten this setup.", - href: "https://github.com/sandwichfarm/loopwire/issues/35", link: "Track simpler APT installs" - }, + detail: "Other Debian versions and ARM64 use the portable path.", + href: "/docs/guide/apt-repository.html", link: "APT repository setup and availability" + }), { id: "fedora", label: "Fedora", heading: "Fedora 44 · x86_64", command: nativeInstall("loopwire-0.1.0-1.fc44.x86_64.rpm", "sudo dnf --setopt=localpkg_gpgcheck=0 install ./loopwire-0.1.0-1.fc44.x86_64.rpm"), diff --git a/package.json b/package.json index b0ae2dc..ae0294b 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", + "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", "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", @@ -66,6 +66,7 @@ "verify:docs-deployment": "node scripts/verify-docs-deployment-manifest.mjs", "verify:docs-live": "bash scripts/verify-docs-live.sh", "verify:packaging": "bash scripts/verify-packaging.sh", + "verify:apt": "bash scripts/verify-apt-repository.sh", "verify: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/repositories/Dockerfile.apt-tools b/packaging/repositories/Dockerfile.apt-tools new file mode 100644 index 0000000..0935599 --- /dev/null +++ b/packaging/repositories/Dockerfile.apt-tools @@ -0,0 +1,10 @@ +FROM debian:13@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1 + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + apt ca-certificates curl dpkg-dev git gnupg gpgv nodejs openssh-client openssh-server openssl python3 \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /run/sshd + +ENV PYTHONDONTWRITEBYTECODE=1 +WORKDIR /workspace diff --git a/packaging/repositories/apt-channel.json b/packaging/repositories/apt-channel.json new file mode 100644 index 0000000..0535f2b --- /dev/null +++ b/packaging/repositories/apt-channel.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": 1, + "status": "pending", + "baseUrl": null, + "signingFingerprint": null, + "revision": null, + "verifiedAt": null, + "proofUrl": null +} diff --git a/packaging/repositories/nginx-apt.conf b/packaging/repositories/nginx-apt.conf new file mode 100644 index 0000000..2ba3c12 --- /dev/null +++ b/packaging/repositories/nginx-apt.conf @@ -0,0 +1,31 @@ +# Include these locations in a TLS-enabled server, with an operator-provisioned +# certificate and server_name. The SSH publisher writes to /srv/loopwire-apt; +# only its public child may be served. snapshots/ and state/ stay private. +# Give the HTTP account read/traverse access to public, never origin write access. +root /srv/loopwire-apt/public; +autoindex off; +disable_symlinks on; + +# Interrupted same-filesystem writes may leave hidden temporary files. +location ~ (^|/)\. { + deny all; +} + +location ~ "^/(pool/.+\.deb|dists/[^/]+/main/binary-amd64/by-hash/SHA256/[0-9a-f]{64}|keys/[A-F0-9]+\.asc)$" { + try_files $uri =404; + error_page 404 = @apt_missing; + add_header Cache-Control "public, max-age=31536000, immutable"; +} + +location @apt_missing { + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + return 404; +} + +# Never cache metadata or missing-file responses at an origin/CDN. InRelease is +# atomic per suite; clients use Acquire-By-Hash for indexes referenced by it. +location / { + try_files $uri =404; + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + etag off; +} diff --git a/packaging/vm/guest-apt-repository-smoke.sh b/packaging/vm/guest-apt-repository-smoke.sh new file mode 100755 index 0000000..b411250 --- /dev/null +++ b/packaging/vm/guest-apt-repository-smoke.sh @@ -0,0 +1,261 @@ +#!/usr/bin/env bash +# The unprivileged guest user owns proof logs; sudo applies only to the package 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}" +case "$target" in ubuntu-24.04 | debian-13) ;; *) echo "unsupported APT guest target" >&2; exit 2 ;; esac +[ "$package_target" = "$target" ] && [ "$format" = deb ] +[[ "$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/apt-fixture" +base_url="https://127.0.0.1:8443" +upgrade_version="${version}+aptfixture1" +[[ "$version" != *+* ]] || upgrade_version="${version}.aptfixture1" +case "$target" in + ubuntu-24.04) suffix=1ubuntu24.04 ;; + debian-13) suffix=1debian13 ;; +esac +baseline_package_version="${version}-${suffix}" +upgrade_package_version="${upgrade_version}-${suffix}" +mkdir -p "$proof_dir/packages" "$proof_dir/repositories" "$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 dpkg-query -W -f='${Status}' loopwire 2>/dev/null | grep -qx 'install ok installed'; then + echo 'clean guest already has Loopwire installed' >&2 + exit 1 +fi +printf 'absent\n' >"$proof_dir/initial-package-status.txt" +( + cd "$kit_dir/release" + sha256sum --check --strict SHA256SUMS >/dev/null + sha256sum loopwire-linux-x86_64.tar.gz +) >"$proof_dir/release-payload.sha256" +tar -xOf "$kit_dir/release/loopwire-linux-x86_64.tar.gz" RELEASE >"$proof_dir/payload-release.txt" + +sudo apt-get update +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + apt-utils ca-certificates curl dpkg-dev gnupg gpgv nodejs openssl python3 xdotool xz-utils xvfb + +# Signing material is generated inside this disposable guest and never copied into evidence. +gnupg_home="$fixture_dir/gnupg" +mkdir -m 0700 "$gnupg_home" +gpg --homedir "$gnupg_home" --batch --pinentry-mode loopback --passphrase '' \ + --quick-generate-key 'Loopwire disposable APT guest fixture' ed25519 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/release-key.pem" +openssl pkey -in "$fixture_dir/release-key.pem" -pubout -out "$fixture_dir/release-public.pem" +cp "$fixture_dir/release-public.pem" "$proof_dir/release-public.pem" + +build_fixture_release() { + local fixture_version="$1" destination="$2" package_distro + mkdir -p "$destination" + for package_distro in ubuntu-24.04 debian-13; do + SOURCE_DATE_EPOCH=0 bash scripts/build-deb-package.sh --target "$package_distro" \ + --version "$fixture_version" --arch x86_64 --release-dir "$kit_dir/release" --output-dir "$destination" + done + (cd "$destination" && sha256sum ./*.deb | sed 's| ./| |' >SHA256SUMS) + openssl dgst -sha256 -sign "$fixture_dir/release-key.pem" -out "$destination/SHA256SUMS.sig" "$destination/SHA256SUMS" + cp "$destination/loopwire_${fixture_version}-${suffix}_amd64.deb" "$proof_dir/packages/" +} + +build_fixture_release "$version" "$fixture_dir/baseline-release" +build_fixture_release "$upgrade_version" "$fixture_dir/upgrade-release" +python3 scripts/apt-repository.py build --release-dir "$fixture_dir/baseline-release" --version "$version" \ + --output "$fixture_dir/initial" --signing-key "$fingerprint" --gnupg-home "$gnupg_home" \ + --release-public-key "$fixture_dir/release-public.pem" +python3 scripts/apt-repository.py build --release-dir "$fixture_dir/upgrade-release" --version "$upgrade_version" \ + --output "$fixture_dir/upgraded" --previous "$fixture_dir/initial" --signing-key "$fingerprint" \ + --gnupg-home "$gnupg_home" --release-public-key "$fixture_dir/release-public.pem" +python3 scripts/apt-repository.py rollback --repository "$fixture_dir/initial" --output "$fixture_dir/rolled-back" \ + --signing-key "$fingerprint" --gnupg-home "$gnupg_home" + +for repository_stage in initial upgraded rolled-back; do + python3 scripts/apt-repository.py verify --repository "$fixture_dir/$repository_stage" \ + --public-key "$proof_dir/repository-key.asc" --fingerprint "$fingerprint" \ + >"$proof_dir/repositories/${repository_stage}-verification.json" + cp -a "$fixture_dir/$repository_stage" "$proof_dir/repositories/$repository_stage" +done + +# A guest-only CA exercises real TLS verification; no production trust is imported. +openssl req -x509 -newkey rsa:2048 -nodes -days 1 -subj '/CN=Loopwire disposable guest CA' \ + -keyout "$fixture_dir/ca-key.pem" -out "$fixture_dir/ca.crt" \ + -addext 'basicConstraints=critical,CA:TRUE' -addext 'keyUsage=critical,keyCertSign,cRLSign' +openssl req -newkey rsa:2048 -nodes -subj '/CN=127.0.0.1' \ + -keyout "$fixture_dir/tls-key.pem" -out "$fixture_dir/tls.csr" +printf 'subjectAltName=IP:127.0.0.1\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\n' >"$fixture_dir/tls.ext" +openssl x509 -req -in "$fixture_dir/tls.csr" -CA "$fixture_dir/ca.crt" -CAkey "$fixture_dir/ca-key.pem" \ + -CAcreateserial -days 1 -extfile "$fixture_dir/tls.ext" -out "$fixture_dir/tls.crt" +cp "$fixture_dir/ca.crt" "$proof_dir/tls-ca.crt" +cp "$fixture_dir/tls.crt" "$proof_dir/tls-server.crt" +sudo install -m 0644 "$fixture_dir/ca.crt" /usr/local/share/ca-certificates/loopwire-guest-fixture.crt +sudo update-ca-certificates +mkdir -p "$fixture_dir/www" +ln -s "$fixture_dir/initial" "$fixture_dir/www/repository" +cat >"$fixture_dir/https-server.py" <<'PY' +import functools +import http.server +import ssl +import sys +class Handler(http.server.SimpleHTTPRequestHandler): + def do_GET(self): + # Fixture revisions may share a filesystem timestamp to the second. + # Always deliver their signed bytes when APT refreshes the source. + 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): + # Debian's APT rejects peers that close TLS without close_notify. + request.settimeout(5) + try: + request.unwrap() + except (OSError, ssl.SSLError): + request.close() +server = Server(("127.0.0.1", 8443), 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/repository" "$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" +python3 scripts/verify-apt-public.py --repository "$fixture_dir/initial" --public-key "$proof_dir/repository-key.asc" \ + --fingerprint "$fingerprint" --base-url "$base_url" --ca-file "$fixture_dir/ca.crt" \ + | tee "$proof_dir/repositories/initial-public-verification.json" +sudo bash scripts/setup-apt-repository.sh --base-url "$base_url" --fingerprint "$fingerprint" \ + >"$proof_dir/bootstrap.log" 2>&1 +cat /etc/apt/sources.list.d/loopwire.sources >"$proof_dir/loopwire.sources" +sudo apt-get update >"$proof_dir/bootstrap-update.log" 2>&1 + +smoke_installed() { + local stage="$1" expected_version="$2" stage_dir="$proof_dir/$1" + mkdir -p "$stage_dir" + dpkg-query -W -f='${Package}\t${Version}\t${Architecture}\t${Status}\n' loopwire >"$stage_dir/package-metadata.tsv" + [ "$(dpkg-query -W -f='${Version}' loopwire)" = "$expected_version" ] + dpkg -L 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" + apt-cache policy loopwire >"$stage_dir/apt-policy.txt" + grep -Fq "$base_url" "$stage_dir/apt-policy.txt" + loopwire --background --help >"$stage_dir/background-help.txt" + loopwire-dsp-provider --help >"$stage_dir/dsp-provider-help.txt" + loopwire-jack-ports --help >"$stage_dir/jack-provider-help.txt" + loopwire-detect-audio --pretty >"$stage_dir/detect-audio.json" + ldd /usr/lib/loopwire/loopwire-gui >"$stage_dir/gui-ldd.txt" + if grep -Fq 'not found' "$stage_dir/gui-ldd.txt"; then + echo 'Installed GUI has unresolved shared libraries' >&2 + return 1 + fi + local gui_status=0 + # Runtime process/window variables must expand in the child shell. + # shellcheck disable=SC2016 + timeout 35s bash -c ' + stage_dir="$1" + app_pid="" + Xvfb :99 -screen 0 1280x720x24 -nolisten tcp >"$stage_dir/xvfb.log" 2>&1 & + xvfb_pid=$! + cleanup_gui() { [ -z "$app_pid" ] || kill "$app_pid" 2>/dev/null || true; kill "$xvfb_pid" 2>/dev/null || true; wait || true; } + trap cleanup_gui EXIT + sleep 1 + DISPLAY=:99 GDK_BACKEND=x11 WEBKIT_DISABLE_DMABUF_RENDERER=1 \ + /usr/lib/loopwire/loopwire-gui >"$stage_dir/gui-launch.log" 2>&1 & + app_pid=$! + for attempt in $(seq 1 20); do + kill -0 "$app_pid" 2>/dev/null || exit 1 + if DISPLAY=:99 xdotool search --name "^(Loopwire|loopwire-gui)$" >"$stage_dir/gui-window-ids.txt" 2>/dev/null; then + while read -r window_id; do DISPLAY=:99 xdotool getwindowname "$window_id"; done \ + <"$stage_dir/gui-window-ids.txt" >"$stage_dir/gui-window-names.txt" + exit 0 + fi + sleep 1 + done + exit 124 + ' bash "$stage_dir" || gui_status=$? + printf '%s\n' "$gui_status" >"$stage_dir/gui-launch-status.txt" + [ "$gui_status" -eq 0 ] + [ -s "$stage_dir/gui-window-ids.txt" ] + if grep -Eiq 'error while loading shared libraries|panic|protocol error|missing acquire timeline' "$stage_dir/gui-launch.log"; then + echo 'Installed GUI reported a startup failure' >&2 + return 1 + fi + printf '%s\t%s\tinstalled\n' "$stage" "$expected_version" >>"$proof_dir/lifecycle.tsv" +} + +sudo DEBIAN_FRONTEND=noninteractive apt-get install -y "loopwire=$baseline_package_version" >"$proof_dir/install.log" 2>&1 +smoke_installed install "$baseline_package_version" +sudo DEBIAN_FRONTEND=noninteractive apt-get install --reinstall -y "loopwire=$baseline_package_version" >"$proof_dir/reinstall.log" 2>&1 +smoke_installed reinstall "$baseline_package_version" +ln -sfn "$fixture_dir/upgraded" "$fixture_dir/www/repository" +python3 scripts/verify-apt-public.py --repository "$fixture_dir/upgraded" --public-key "$proof_dir/repository-key.asc" \ + --fingerprint "$fingerprint" --base-url "$base_url" --ca-file "$fixture_dir/ca.crt" \ + | tee "$proof_dir/repositories/upgraded-public-verification.json" +sudo apt-get update >"$proof_dir/upgrade-update.log" 2>&1 +sudo DEBIAN_FRONTEND=noninteractive apt-get install --only-upgrade -y loopwire >"$proof_dir/upgrade.log" 2>&1 +smoke_installed upgrade "$upgrade_package_version" +ln -sfn "$fixture_dir/rolled-back" "$fixture_dir/www/repository" +python3 scripts/verify-apt-public.py --repository "$fixture_dir/rolled-back" --public-key "$proof_dir/repository-key.asc" \ + --fingerprint "$fingerprint" --base-url "$base_url" --ca-file "$fixture_dir/ca.crt" \ + | tee "$proof_dir/repositories/rolled-back-public-verification.json" +sudo apt-get update >"$proof_dir/rollback-update.log" 2>&1 +sudo DEBIAN_FRONTEND=noninteractive apt-get install --allow-downgrades -y "loopwire=$baseline_package_version" >"$proof_dir/rollback.log" 2>&1 +smoke_installed rollback "$baseline_package_version" +sudo DEBIAN_FRONTEND=noninteractive apt-get remove -y loopwire >"$proof_dir/remove.log" 2>&1 +if dpkg-query -W loopwire >/dev/null 2>&1; then + echo 'Loopwire remains registered after removal' >&2 + exit 1 +fi +for removed_file in /usr/bin/loopwire /usr/bin/loopwire-dsp-provider /usr/bin/loopwire-jack-ports \ + /usr/bin/loopwire-detect-audio /usr/lib/loopwire /usr/share/applications/loopwire.desktop \ + /usr/share/icons/hicolor/scalable/apps/loopwire.svg; do + test ! -e "$removed_file" + printf '%s\tabsent\n' "$removed_file" >>"$proof_dir/removed-files.tsv" +done +printf 'remove\t%s\tabsent\n' "$baseline_package_version" >>"$proof_dir/lifecycle.tsv" +sudo bash scripts/setup-apt-repository.sh --remove >"$proof_dir/source-removal.log" 2>&1 +test ! -e /etc/apt/sources.list.d/loopwire.sources +test ! -e "/etc/apt/keyrings/loopwire-$fingerprint.asc" +sudo apt-get update >"$proof_dir/source-removal-update.log" 2>&1 +apt-cache policy loopwire >"$proof_dir/source-removal-policy.txt" +if grep -Fq "$base_url" "$proof_dir/source-removal-policy.txt"; then + echo 'APT still lists the removed repository' >&2 + exit 1 +fi +cat >"$proof_dir/summary.tsv" <= minimum, f"invalid {label}") + return value + + +def object_pairs(pairs): + result = {} + for key, value in pairs: + require(key not in result, f"duplicate JSON field: {key}") + result[key] = value + return result + + +def read_json(path): + return json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=object_pairs) + + +def safe_path(value): + require(isinstance(value, str) and value and not any(ord(char) < 33 or ord(char) > 126 for char in value), + "inventory paths must contain printable ASCII without whitespace") + path = PurePosixPath(value) + require(not path.is_absolute() and str(path) == value and ".." not in path.parts and "\\" not in value, + f"unsafe inventory path: {value}") + return value + + +def classify_path(value): + safe_path(value) + if re.fullmatch(rf"keys/{FINGERPRINT}\.asc", value): + return "immutable" + for suite, revision in SUITES.items(): + if re.fullmatch(rf"pool/{re.escape(suite)}/main/l/loopwire/loopwire_{VERSION}-1{revision}_amd64\.deb", value): + return "immutable" + prefix = f"dists/{suite}/" + if value in (prefix + "InRelease", prefix + "Release", + prefix + "main/binary-amd64/Packages", prefix + "main/binary-amd64/Packages.gz"): + return "metadata" + if re.fullmatch(re.escape(prefix) + rf"main/binary-amd64/by-hash/SHA256/{HASH}", value): + return "immutable" + raise RepositoryError(f"path is outside the APT repository contract: {value}") + + +def regular_file(path): + info = path.lstat() + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, + f"only regular files without symlinks or hardlinks are allowed: {path}") + return info + + +def tree_files(root): + require(root.is_dir() and not root.is_symlink(), "repository must be a real directory") + files = set() + for directory, directories, names in os.walk(root, followlinks=False): + for name in directories: + path = Path(directory) / name + require(not path.is_symlink(), f"symlink directory is forbidden: {path}") + for name in names: + path = Path(directory) / name + regular_file(path) + files.add(path.relative_to(root).as_posix()) + return files + + +def parse_control(data): + text = data.decode("utf-8") if isinstance(data, bytes) else data + require("\r" not in text and "\x00" not in text, "invalid control-file characters") + records = [] + current = {} + field = None + for line in text.splitlines(): + if not line: + if current: + records.append(current) + current = {} + field = None + elif line[0] in " \t": + require(field is not None, "control-file continuation without a field") + current[field] += "\n" + line + else: + require(":" in line, "malformed control-file field") + key, value = line.split(":", 1) + require(re.fullmatch(r"[A-Za-z][A-Za-z0-9-]*", key) is not None, "invalid control-file field name") + field = key.lower() + require(field not in current, f"duplicate control-file field: {key}") + current[field] = value.lstrip(" ") + if current: + records.append(current) + return records + + +def single_control(data): + records = parse_control(data) + require(len(records) == 1, "expected one control-file record") + return records[0] + + +def package_info(root, path, suite): + classify_path(path) + require(path.startswith(f"pool/{suite}/"), "package belongs to the wrong suite") + file = root / path + regular_file(file) + raw = run("dpkg-deb", "--field", file) + control = single_control(raw) + require(control.get("package") == "loopwire", "repository only accepts Package: loopwire") + require(control.get("architecture") == "amd64", "repository only accepts Architecture: amd64") + version = control.get("version", "") + require(re.fullmatch(rf"{VERSION}-1{SUITES[suite]}", version) is not None, + f"package version does not match suite {suite}: {version}") + require(Path(path).name == f"loopwire_{version}_amd64.deb", "package filename does not match control identity") + require(not ({"filename", "size", "md5sum", "sha1", "sha256", "sha512"} & set(control)), + "package control must not supply repository-owned hash/path fields") + return {"name": "loopwire", "version": version, "architecture": "amd64", "path": path, + "sha256": sha256(file), "size": file.stat().st_size}, raw.rstrip(b"\n") + + +def key_fingerprint(key, home): + data = run("gpg", "--batch", "--homedir", home, "--with-colons", "--import-options", "show-only", + "--import", key).decode() + primary = [] + want_fingerprint = False + for line in data.splitlines(): + fields = line.split(":") + if fields[0] == "pub": + want_fingerprint = True + elif fields[0] == "fpr" and want_fingerprint: + primary.append(fingerprint(fields[9])) + want_fingerprint = False + elif fields[0] == "sub": + want_fingerprint = False + require(len(primary) == 1, "public key must contain exactly one primary OpenPGP key") + return primary[0] + + +def signed_release(root, suite, ring, home, expected_fingerprint): + decoded = Path(home) / f"{suite}.Release" + status = run("gpgv", "--homedir", home, "--keyring", ring, "--status-fd", "1", + "--output", decoded, root / f"dists/{suite}/InRelease").decode() + valid = [line.split() for line in status.splitlines() if line.startswith("[GNUPG:] VALIDSIG ")] + require(len(valid) == 1 and valid[0][-1] == expected_fingerprint, + f"{suite}: signature does not match the pinned primary fingerprint") + require(not any(f"[GNUPG:] {flag}" in status for flag in + ("EXPKEYSIG", "EXPSIG", "REVKEYSIG", "KEYREVOKED", "KEYEXPIRED", "SIGEXPIRED")), + f"{suite}: expired or revoked signing identity") + data = decoded.read_bytes() + require(data == (root / f"dists/{suite}/Release").read_bytes(), + f"{suite}: Release differs from signed InRelease payload") + return single_control(data) + + +def manifest_revision(manifest): + unsigned = {key: value for key, value in manifest.items() if key != "revision"} + return hashlib.sha256(canonical(unsigned)).hexdigest() + + +def load_inventory(root): + actual = tree_files(root) + require(MANIFEST in actual, "missing repository-manifest.json") + manifest = read_json(root / MANIFEST) + exact_keys(manifest, {"schemaVersion", "revision", "createdAt", "validUntil", "signingFingerprint", "suites", "files"}, + "repository manifest") + require(type(manifest["schemaVersion"]) is int and manifest["schemaVersion"] == 1, "unsupported manifest schema") + fingerprint(manifest["signingFingerprint"]) + integer(manifest["createdAt"], "createdAt") + integer(manifest["validUntil"], "validUntil") + require(manifest["validUntil"] > manifest["createdAt"], "metadata expiry must follow creation") + require(manifest["revision"] == manifest_revision(manifest), "manifest revision digest mismatch") + require(isinstance(manifest["files"], list), "files must be an array") + inventory = {} + for entry in manifest["files"]: + exact_keys(entry, {"path", "sha256", "size", "kind"}, "inventory entry") + path = safe_path(entry["path"]) + require(path not in inventory, f"duplicate inventory path: {path}") + require(entry["kind"] == classify_path(path), f"incorrect file classification: {path}") + integer(entry["size"], "file size") + require(isinstance(entry["sha256"], str) and re.fullmatch(HASH, entry["sha256"]) is not None, + f"invalid SHA256: {path}") + require(path in actual, f"missing inventory file: {path}") + require((root / path).stat().st_size == entry["size"] and sha256(root / path) == entry["sha256"], + f"inventory checksum mismatch: {path}") + if "/by-hash/" in path: + require(Path(path).name == entry["sha256"], f"by-hash filename/content mismatch: {path}") + inventory[path] = entry + require(actual == set(inventory) | {MANIFEST}, "repository has unlisted files") + return manifest, inventory + + +def verify_repository(root, public_key, expected_fingerprint, now=None): + """Verify inventory, pinned signatures, index hashes, and exact package identities.""" + manifest, inventory = load_inventory(root) + expected = fingerprint(expected_fingerprint) if expected_fingerprint else manifest["signingFingerprint"] + require(expected == manifest["signingFingerprint"], "manifest fingerprint differs from operator pin") + now = int(time.time()) if now is None else integer(now, "verification time") + require(manifest["createdAt"] <= now + 10, "repository metadata is from the future") + require(manifest["validUntil"] > now, "repository metadata has expired; refresh and re-sign it") + require(isinstance(manifest["suites"], list) and len(manifest["suites"]) == len(SUITES), + "repository must contain exactly both supported suites") + with tempfile.TemporaryDirectory(prefix="loopwire-apt-verify-") as temporary: + home = Path(temporary) + require(key_fingerprint(public_key, home) == expected, "trusted public key differs from operator pin") + ring = home / "trusted.gpg" + run("gpg", "--batch", "--homedir", home, "--dearmor", "--output", ring, public_key) + for path in inventory: + if path.startswith("keys/"): + require(key_fingerprint(root / path, home) == Path(path).stem, "exported key fingerprint/path mismatch") + key_path = f"keys/{expected}.asc" + require(key_path in inventory, "missing fingerprint-addressed bootstrap key") + # The candidate's bootstrap asset must actually verify the same metadata, + # not merely carry another packet set with the same primary fingerprint. + exported_ring = home / "exported.gpg" + run("gpg", "--batch", "--homedir", home, "--dearmor", "--output", exported_ring, root / key_path) + seen_suites = set() + for suite in manifest["suites"]: + exact_keys(suite, {"name", "architecture", "component", "packages"}, "suite") + name = suite["name"] + require(name in SUITES and name not in seen_suites, "invalid or duplicate suite") + seen_suites.add(name) + require(suite["architecture"] == "amd64" and suite["component"] == "main", "unsupported suite layout") + release = signed_release(root, name, ring, home, expected) + (home / f"{name}.Release").unlink() + signed_release(root, name, exported_ring, home, expected) + require(set(release) == {"origin", "label", "suite", "codename", "architectures", "components", + "date", "valid-until", "acquire-by-hash", "sha256"}, + "signed Release fields are outside the supported repository contract") + require(release.get("origin") == "Loopwire" and release.get("label") == "Loopwire", + "incorrect repository identity") + require(release.get("suite") == name and release.get("codename") == name, "signed suite mismatch") + require(release.get("architectures") == "amd64" and release.get("components") == "main", + "signed architecture/component mismatch") + require(release.get("acquire-by-hash") == "yes", "signed metadata must enable by-hash") + for field, expected_time in (("date", manifest["createdAt"]), ("valid-until", manifest["validUntil"])): + date = parsedate_to_datetime(release.get(field, "")) + require(date.tzinfo is not None and int(date.timestamp()) == expected_time, + f"signed {field} differs from inventory") + hashes = {} + for row in release.get("sha256", "").splitlines(): + if not row.strip(): + continue + parts = row.split() + require(len(parts) == 3 and re.fullmatch(HASH, parts[0]) and parts[1].isdigit(), + "invalid signed SHA256 row") + checksum, size, path = parts + require(path not in hashes, "duplicate signed index path") + hashes[path] = (checksum, int(size)) + require(set(hashes) == {"main/binary-amd64/Packages", "main/binary-amd64/Packages.gz"}, + "signed metadata must cover exactly the supported package indexes") + for path, (checksum, size) in hashes.items(): + canonical_path = f"dists/{name}/{path}" + by_hash = f"dists/{name}/main/binary-amd64/by-hash/SHA256/{checksum}" + for candidate in (canonical_path, by_hash): + require(candidate in inventory and inventory[candidate]["sha256"] == checksum + and inventory[candidate]["size"] == size, f"signed index checksum mismatch: {candidate}") + index = (root / f"dists/{name}/main/binary-amd64/Packages").read_bytes() + require(gzip.decompress((root / f"dists/{name}/main/binary-amd64/Packages.gz").read_bytes()) == index, + "compressed index does not match Packages") + records = parse_control(index) + require(isinstance(suite["packages"], list) and suite["packages"], "suite has no packages") + require(len(records) == len(suite["packages"]), "package inventory/index count mismatch") + packages = {} + versions = set() + for entry in suite["packages"]: + exact_keys(entry, PACKAGE_FIELDS, "package") + path = safe_path(entry["path"]) + require(path not in packages and path in inventory, "duplicate or missing package inventory path") + info, _raw = package_info(root, path, name) + require(entry == info, "package identity/hash does not match inventory") + require(info["version"] not in versions, "duplicate package version") + versions.add(info["version"]) + packages[path] = entry + seen = set() + for record in records: + path = record.get("filename") + require(path in packages and path not in seen, "index contains missing or duplicate package") + seen.add(path) + package = packages[path] + require(record.get("package") == package["name"] and record.get("version") == package["version"] + and record.get("architecture") == package["architecture"], "signed package identity mismatch") + require(record.get("sha256") == package["sha256"] and record.get("size") == str(package["size"]), + "signed package checksum mismatch") + require(seen_suites == set(SUITES), "missing suite") + # Old immutable packages are outside current indexes after rollback, but + # still need to satisfy the allowed native-package identity contract. + for path in inventory: + if path.startswith("pool/"): + package_info(root, path, path.split("/")[1]) + return manifest + + +def export_signer(args, directory): + expected = fingerprint(args.signing_key) + gpg = ["gpg", "--batch", "--no-tty"] + if args.gnupg_home: + gpg.extend(["--homedir", str(args.gnupg_home)]) + if args.passphrase_file: + regular_file(args.passphrase_file) + gpg.extend(["--pinentry-mode", "loopback", "--passphrase-file", str(args.passphrase_file)]) + key = directory / "signer.asc" + key.write_bytes(run(*gpg, "--export-options", "export-minimal", "--armor", "--export", expected)) + require(key.stat().st_size > 0, "signing public key is missing") + require(key_fingerprint(key, directory) == expected, "signing key must identify one primary key") + return gpg, key, expected + + +def previous_repository(path, key, expected): + # Refresh/rollback must work after expiry. Authenticate the old snapshot at + # its signed Date; current validity is checked again on the newly signed output. + manifest, _inventory = load_inventory(path) + return verify_repository(path, key, expected, manifest["createdAt"]) + + +def copy_immutable(source, target, manifest): + for entry in manifest["files"]: + if entry["kind"] == "immutable": + destination = target / entry["path"] + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(source / entry["path"], destination) + require(sha256(destination) == entry["sha256"] and destination.stat().st_size == entry["size"], + "previous immutable file changed while copying the snapshot") + + +def source_packages(args, output): + require(re.fullmatch(VERSION, args.version) is not None, + "APT publication requires X.Y.Z with optional +build metadata; prereleases need a separate version policy") + source = args.release_dir + checksums = source / "SHA256SUMS" + signature = source / "SHA256SUMS.sig" + regular_file(checksums) + regular_file(signature) + run("openssl", "dgst", "-sha256", "-verify", args.release_public_key, + "-signature", signature, checksums) + signed = {} + for line in checksums.read_text(encoding="utf-8").splitlines(): + match = re.fullmatch(rf"({HASH}) [ *]([^/\\\s]+)", line) + require(match is not None, "invalid signed release checksum line") + checksum, name = match.groups() + require(name not in signed and name not in (".", ".."), "duplicate or invalid release checksum asset") + signed[name] = checksum + expected_assets = {f"loopwire_{args.version}-1{revision}_amd64.deb" for revision in SUITES.values()} + actual_debs = {path.name for path in source.glob("*.deb")} + require(actual_debs == expected_assets, "release must contain exactly the two expected native amd64 .deb files") + result = {} + for suite, revision in SUITES.items(): + name = f"loopwire_{args.version}-1{revision}_amd64.deb" + original = source / name + regular_file(original) + require(name in signed and sha256(original) == signed[name], f"release package checksum mismatch: {name}") + path = f"pool/{suite}/main/l/loopwire/{name}" + destination = output / path + destination.parent.mkdir(parents=True, exist_ok=True) + if destination.exists(): + require(sha256(destination) == signed[name], f"same package version has different bytes: {name}") + else: + shutil.copyfile(original, destination) + require(sha256(destination) == signed[name], "release package changed while staging its signed bytes") + result[suite], _raw = package_info(output, path, suite) + return result + + +def write_candidate(args, rollback=False): + output = args.output + require(not output.exists() and not output.is_symlink(), "output must not already exist; reuse the completed candidate for publication retries") + date = int(time.time()) if args.date is None else integer(args.date, "date") + require(1 <= args.valid_for_days <= 90, "valid-for-days must be between 1 and 90") + valid_until = date + args.valid_for_days * 86400 + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=f".{output.name}-", dir=output.parent) as temporary: + staging_root = Path(temporary) + working = staging_root / "repository" + working.mkdir() + gpg, key, expected = export_signer(args, staging_root) + previous_path = args.repository if rollback else args.previous + previous = previous_repository(previous_path, key, expected) if previous_path else None + if previous: + require(date >= previous["createdAt"], "new metadata Date must not precede the previous revision") + copy_immutable(previous_path, working, previous) + suites = previous["suites"] if previous else [ + {"name": name, "architecture": "amd64", "component": "main", "packages": []} for name in SUITES] + if not rollback: + added = source_packages(args, working) + for suite in suites: + package = added[suite["name"]] + for old in suite["packages"]: + comparison = subprocess.run(["dpkg", "--compare-versions", package["version"], "ge", old["version"]], + stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) + require(comparison.returncode == 0, + "new package version is lower than a published version; use explicit rollback") + if not any(old["version"] == package["version"] for old in suite["packages"]): + suite["packages"].append(package) + suite["packages"].sort(key=lambda entry: entry["path"]) + exported = working / f"keys/{expected}.asc" + exported.parent.mkdir(exist_ok=True) + if exported.exists(): + require(exported.read_bytes() == key.read_bytes(), + "fingerprint-addressed key bytes changed; rotate with a new identity and bootstrap trust first") + else: + shutil.copyfile(key, exported) + for suite in suites: + name = suite["name"] + suite_dir = working / f"dists/{name}" + binary = suite_dir / "main/binary-amd64" + by_hash = binary / "by-hash/SHA256" + by_hash.mkdir(parents=True, exist_ok=True) + paragraphs = [] + for entry in suite["packages"]: + info, raw = package_info(working, entry["path"], name) + require(info == entry, "retained package differs from validated inventory") + paragraphs.append(raw + (f"\nFilename: {entry['path']}\nSize: {entry['size']}\n" + f"SHA256: {entry['sha256']}\n\n").encode()) + index = b"".join(paragraphs) + (binary / "Packages").write_bytes(index) + (binary / "Packages.gz").write_bytes(gzip.compress(index, compresslevel=9, mtime=0)) + hash_rows = [] + for index_name in ("Packages", "Packages.gz"): + file = binary / index_name + checksum = sha256(file) + immutable = by_hash / checksum + if immutable.exists(): + require(sha256(immutable) == checksum, "immutable by-hash collision") + else: + shutil.copyfile(file, immutable) + hash_rows.append(f" {checksum} {file.stat().st_size} main/binary-amd64/{index_name}\n") + release = ("Origin: Loopwire\nLabel: Loopwire\n" + f"Suite: {name}\nCodename: {name}\nArchitectures: amd64\nComponents: main\n" + f"Date: {format_datetime(datetime.fromtimestamp(date, timezone.utc), usegmt=True)}\n" + f"Valid-Until: {format_datetime(datetime.fromtimestamp(valid_until, timezone.utc), usegmt=True)}\n" + "Acquire-By-Hash: yes\nSHA256:\n" + "".join(hash_rows)) + (suite_dir / "Release").write_text(release, encoding="utf-8") + run(*gpg, "--yes", "--faked-system-time", f"{date}!", "--digest-algo", "SHA256", + "--local-user", expected, "--armor", "--clearsign", "--output", suite_dir / "InRelease", + suite_dir / "Release") + files = [{"path": path, "sha256": sha256(working / path), "size": (working / path).stat().st_size, + "kind": classify_path(path)} for path in sorted(tree_files(working))] + manifest = {"schemaVersion": 1, "createdAt": date, "validUntil": valid_until, + "signingFingerprint": expected, "suites": suites, "files": files} + manifest["revision"] = manifest_revision(manifest) + (working / MANIFEST).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + verify_repository(working, key, expected, date) + working.rename(output) + return manifest + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + build = commands.add_parser("build", help="generate a candidate from signed native release assets") + build.add_argument("--release-dir", type=Path, required=True) + build.add_argument("--version", required=True) + build.add_argument("--release-public-key", type=Path, default=ROOT / "packaging/release-signing-public.pem", + help="trusted release checksum PEM (override for fixture keys)") + build.add_argument("--previous", type=Path, help="verified previous snapshot whose immutable history is retained") + rollback = commands.add_parser("rollback", help="freshly sign a previous snapshot's package set (also refreshes expiry)") + rollback.add_argument("--repository", type=Path, required=True) + for command in (build, rollback): + command.add_argument("--output", type=Path, required=True) + command.add_argument("--signing-key", required=True, help="uppercase primary OpenPGP fingerprint") + command.add_argument("--gnupg-home", type=Path, help="isolated GnuPG home containing the signing identity") + command.add_argument("--passphrase-file", type=Path, + help="optional protected file containing the signing-key passphrase; never pass it as a value") + command.add_argument("--date", type=int, help="metadata/signature creation time as Unix epoch seconds (default: now)") + command.add_argument("--valid-for-days", type=int, default=30) + verify = commands.add_parser("verify", help="verify the inventory and complete pinned APT trust chain") + verify.add_argument("--repository", type=Path, required=True) + verify.add_argument("--public-key", type=Path, required=True, help="independently trusted ASCII-armored public key") + verify.add_argument("--fingerprint", help="expected uppercase primary fingerprint (otherwise derived from trusted public key)") + verify.add_argument("--now", type=int, help="explicit verification time for fixture or historical-snapshot validation") + args = parser.parse_args() + if args.command == "verify": + manifest = verify_repository(args.repository, args.public_key, args.fingerprint, args.now) + else: + manifest = write_candidate(args, rollback=args.command == "rollback") + print(json.dumps({key: manifest[key] for key in + ("revision", "signingFingerprint", "createdAt", "validUntil", "suites")}, sort_keys=True)) + + +if __name__ == "__main__": + try: + main() + except (RepositoryError, OSError, ValueError, KeyError, TypeError, EOFError, OverflowError) as error: + print(f"apt-repository: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/native-package-vm.sh b/scripts/native-package-vm.sh index 0378cda..3f41104 100755 --- a/scripts/native-package-vm.sh +++ b/scripts/native-package-vm.sh @@ -4,6 +4,8 @@ set -euo pipefail root="$(git rev-parse --show-toplevel 2>/dev/null || true)" manifest="${LOOPWIRE_NATIVE_VM_TARGETS:-packaging/vm/native-package-targets.tsv}" vm_root="${LOOPWIRE_NATIVE_VM_ROOT:-.vm/native-packages}" +image_root="$vm_root" +proof_kind="native" qemu_image="${LOOPWIRE_QEMU_IMAGE:-loopwire-native-package-qemu:ubuntu-24.04}" ssh_user="loopwire" active_vm_container="" @@ -21,15 +23,19 @@ Usage: native-package-vm.sh run-all --version VERSION --release-dir DIR native-package-vm.sh verify --target TARGET [--git-head COMMIT] native-package-vm.sh verify-all [--git-head COMMIT] + native-package-vm.sh run-apt --target ubuntu-24.04|debian-13 --version VERSION --release-dir DIR + native-package-vm.sh verify-apt --target ubuntu-24.04|debian-13 [--git-head COMMIT] 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_NATIVE_VM_TARGETS Target manifest override LOOPWIRE_QEMU_IMAGE Docker QEMU tool image tag The host needs Docker, OpenSSH, /dev/kvm access, and enough disk for the official cloud images. Containers only provide QEMU tools; every proof is collected from -a separately booted guest kernel and checked by verify-native-package-vm-proof.mjs. +a separately booted guest kernel. APT proofs use verify-apt-repository-vm-proof.mjs; +the original native-package proofs use verify-native-package-vm-proof.mjs. USAGE } @@ -113,7 +119,7 @@ image_path_for() { local id="$1" url="$2" local suffix="${url##*.}" case "$suffix" in img | qcow2) ;; *) suffix="qcow2" ;; esac - printf '%s/images/%s.%s\n' "$vm_root" "$id" "$suffix" + printf '%s/images/%s.%s\n' "$image_root" "$id" "$suffix" } download_target() { @@ -262,6 +268,10 @@ run_target() { evidence_parent="$(realpath -m "$vm_root/evidence/$id")" evidence_dir="$evidence_parent/$git_head" container="loopwire-native-$id" + if [ "$proof_kind" = "apt" ]; then + container="loopwire-apt-$id" + port=$((port + 10)) + fi key="$(ensure_ssh_key)" public_key="$(cat "${key}.pub")" known_hosts="$target_dir/known_hosts" @@ -306,8 +316,17 @@ run_target() { mapfile -d '' -t ssh_args < <(ssh_args_for "$key" "$port" "$known_hosts") ssh "${ssh_args[@]}" "$ssh_user@127.0.0.1" 'rm -rf /home/loopwire/loopwire-native-kit' copy_to_guest "$key" "$port" "$known_hosts" "$target_dir/kit" '/home/loopwire/loopwire-native-kit' + local guest_script="packaging/vm/guest-native-package-smoke.sh" + local verifier="scripts/verify-native-package-vm-proof.mjs" + if [ "$proof_kind" = "apt" ]; then + guest_script="packaging/vm/guest-apt-repository-smoke.sh" + verifier="scripts/verify-apt-repository-vm-proof.mjs" + fi + local guest_status=0 + # Script paths are fixed and all client-expanded arguments are validated above. + # shellcheck disable=SC2029 ssh "${ssh_args[@]}" "$ssh_user@127.0.0.1" \ - "cd /home/loopwire/loopwire-native-kit && bash packaging/vm/guest-native-package-smoke.sh '$id' '$package_target' '$format' '$version' '$git_head' \"\$PWD\"" + "cd /home/loopwire/loopwire-native-kit && bash '$guest_script' '$id' '$package_target' '$format' '$version' '$git_head' \"\$PWD\"" || guest_status=$? mkdir -p "$evidence_dir" ssh "${ssh_args[@]}" "$ssh_user@127.0.0.1" \ 'tar -C /home/loopwire/loopwire-native-kit/proof -cf - .' | tar -xf - -C "$evidence_dir" @@ -322,13 +341,16 @@ run_target() { checksum "$checksum" \ actual_checksum "$(checksum_file "$algorithm" "$image_path")" \ firmware "$firmware" >"$evidence_dir/image.tsv" - node scripts/verify-native-package-vm-proof.mjs \ + [ "$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" cleanup_active_vm active_vm_container="" active_vm_console="" trap - EXIT INT TERM - echo "Verified native package in matching KVM guest: $id" + local proof_label="native package" + [ "$proof_kind" != "apt" ] || proof_label="APT repository lifecycle" + echo "Verified $proof_label in matching KVM guest: $id" echo "Evidence: $evidence_dir" } @@ -336,7 +358,9 @@ verify_target() { local selected="$1" git_head="$2" validate_target_value "$selected" [ -n "$git_head" ] || git_head="$(git rev-parse HEAD)" - node scripts/verify-native-package-vm-proof.mjs \ + local verifier="scripts/verify-native-package-vm-proof.mjs" + [ "$proof_kind" != "apt" ] || verifier="scripts/verify-apt-repository-vm-proof.mjs" + node "$verifier" \ --target "$selected" --evidence-dir "$vm_root/evidence/$selected/$git_head" --git-head "$git_head" } @@ -366,6 +390,16 @@ while [ "$#" -gt 0 ]; do esac done +case "$command" in + run-apt | verify-apt) + case "$selected" in ubuntu-24.04 | debian-13) ;; *) fail "$command requires an Ubuntu 24.04 or Debian 13 target" ;; esac + proof_kind="apt" + vm_root="${LOOPWIRE_APT_VM_ROOT:-.vm/apt-repository}" + [ "$(realpath -m "$vm_root")" != "$(realpath -m "$image_root")" ] || + fail "APT VM state must use a different root from native-package state" + ;; +esac + case "$command" in list) printf '%-22s %-24s %-5s %-5s\n' TARGET DISTRO TYPE PORT @@ -382,7 +416,7 @@ case "$command" in require_host while read -r id; do download_target "$id"; done < <(target_ids) ;; - run) + run | run-apt) [ -n "$selected" ] || fail "run requires --target" [ -n "$version" ] || fail "run requires --version" [ -n "$release_dir" ] || fail "run requires --release-dir" @@ -393,7 +427,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 | verify-apt) [ -n "$selected" ] || fail "verify requires --target" verify_target "$selected" "$git_head" ;; diff --git a/scripts/publish-apt-workflow.sh b/scripts/publish-apt-workflow.sh new file mode 100755 index 0000000..3787cc8 --- /dev/null +++ b/scripts/publish-apt-workflow.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# Entrypoint for protected GitHub release publication and metadata maintenance. +set -euo pipefail + +fail() { printf 'publish-apt-workflow: %s\n' "$*" >&2; exit 1; } +for name in APT_REPOSITORY_URL APT_REPOSITORY_HOST APT_REPOSITORY_ROOT APT_SIGNING_FINGERPRINT \ + APT_SSH_PRIVATE_KEY APT_SSH_KNOWN_HOSTS APT_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 +[[ "$APT_SIGNING_FINGERPRINT" =~ ^[A-F0-9]{40}$ ]] || fail "signing fingerprint must be complete uppercase hexadecimal" +[[ "${APT_SSH_PORT:-22}" =~ ^[0-9]+$ ]] || fail "SSH port must be numeric" +python3 - "$APT_REPOSITORY_URL" <<'PY' +import runpy, sys +validate = runpy.run_path('scripts/verify-apt-public.py')['validate_base_url'] +try: + validate(sys.argv[1]) +except ValueError as error: + sys.exit(f'publish-apt-workflow: {error}') +PY + +work="$(mktemp -d "$RUNNER_TEMP/loopwire-apt.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' "$APT_SSH_PRIVATE_KEY" >"$work/ssh-key" +printf '%s\n' "$APT_SSH_KNOWN_HOSTS" >"$work/known-hosts" +printf '%s\n' "$APT_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 "$APT_SIGNING_FINGERPRINT" >"$work/public-key.asc" +[ -s "$work/public-key.asc" ] || fail "configured key does not match the expected fingerprint" +sign_args=(--signing-key "$APT_SIGNING_FINGERPRINT" --gnupg-home "$work/gnupg" --valid-for-days 30) +if [ -n "${APT_SIGNING_PASSPHRASE:-}" ]; then + printf '%s' "$APT_SIGNING_PASSPHRASE" >"$work/passphrase" + sign_args+=(--passphrase-file "$work/passphrase") +fi +unset APT_SSH_PRIVATE_KEY APT_SSH_KNOWN_HOSTS APT_SIGNING_KEY APT_SIGNING_PASSPHRASE +transport=(--root "$APT_REPOSITORY_ROOT" --ssh "$APT_REPOSITORY_HOST" --ssh-port "${APT_SSH_PORT:-22}" + --identity-file "$work/ssh-key" --known-hosts "$work/known-hosts" + --public-key "$work/public-key.asc" --fingerprint "$APT_SIGNING_FINGERPRINT") + +set +e +python3 scripts/publish-package-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 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('APT 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/apt-repository.py build --release-dir "$work/release" --version "${RELEASE_TAG#v}" \ + --output "$work/candidate" "${sign_args[@]}" "${previous_args[@]}" + ;; + refresh) + python3 scripts/apt-repository.py rollback --repository "$work/current" --output "$work/candidate" "${sign_args[@]}" + ;; + rollback) + python3 scripts/publish-package-repository.py fetch "${transport[@]}" --revision "$ROLLBACK_REVISION" \ + --output "$work/rollback" + python3 scripts/apt-repository.py rollback --repository "$work/rollback" --output "$work/candidate" "${sign_args[@]}" + ;; +esac + +mkdir -p dist/apt-publication +python3 scripts/publish-package-repository.py publish "${transport[@]}" --repository "$work/candidate" \ + --expected-revision "$expected" >dist/apt-publication/publication.json +python3 scripts/verify-apt-public.py --repository "$work/candidate" --public-key "$work/public-key.asc" \ + --fingerprint "$APT_SIGNING_FINGERPRINT" --base-url "$APT_REPOSITORY_URL" \ + --proof-url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + --output dist/apt-publication/apt-channel.json +cp "$work/candidate/repository-manifest.json" dist/apt-publication/repository-manifest.json +printf 'APT repository published and verified; activation record is in the workflow artifact.\n' diff --git a/scripts/publish-package-repository.py b/scripts/publish-package-repository.py new file mode 100644 index 0000000..f72bf04 --- /dev/null +++ b/scripts/publish-package-repository.py @@ -0,0 +1,621 @@ +#!/usr/bin/env python3 +"""Publish verified APT trees to a POSIX origin; no third-party Python modules. + +ROOT/public is the HTTP document root. ROOT/snapshots and ROOT/state are private. +Snapshots and pool/by-hash URLs are retained indefinitely. Each suite's InRelease +is an independent atomic commit point, not a transaction across suites. The +durable pending journal must be completed before any competing publication. + +The SSH transport runs this same source with Python on the origin. All arguments +are encoded data, host keys must already be trusted, and no credentials are sent +in arguments or output. Signature verification happens on the client; the +authenticated transport and origin independently check the full file inventory. +The origin needs Python 3, SSH, and a dedicated account with exclusive ownership +of ROOT on a filesystem implementing flock, fsync, and same-directory rename. +""" + +import argparse +import base64 +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import re +import shlex +import shutil +import stat +import subprocess +import sys +import tarfile +import tempfile +import time + + +SUITES = ("debian-13", "ubuntu-24.04") +MANIFEST = "repository-manifest.json" +REVISION = re.compile(r"[0-9a-f]{64}\Z") +FINGERPRINT = re.compile(r"(?:[A-F0-9]{40}|[A-F0-9]{64})\Z") + + +class PublicationError(Exception): + """An actionable publication failure, with no private transport details.""" + + +class EmptyRepository(PublicationError): + """No committed snapshot exists (distinct exit status 3).""" + + +def require(condition, message): + if not condition: + raise PublicationError(message) + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def digest(path): + with path.open("rb") as source: + return hashlib.file_digest(source, "sha256").hexdigest() if hasattr(hashlib, "file_digest") else _digest_stream(source) + + +def _digest_stream(source): + result = hashlib.sha256() + for chunk in iter(lambda: source.read(1024 * 1024), b""): + result.update(chunk) + return result.hexdigest() + + +def safe_path(value): + require(isinstance(value, str) and value and "\\" not in value, + "inventory contains an invalid path") + path = PurePosixPath(value) + require(not path.is_absolute() and path.as_posix() == value + and all(part not in (".", "..") for part in path.parts), + "inventory path must be normalized and relative") + return path + + +def classify(path): + """Derive ownership from the protocol, never from an arbitrary manifest kind.""" + safe_path(path) + if re.fullmatch(r"pool/(ubuntu-24\.04|debian-13)/main/l/loopwire/[A-Za-z0-9][A-Za-z0-9.+_~-]*\.deb", path): + return "immutable" + if re.fullmatch(r"keys/(?:[A-F0-9]{40}|[A-F0-9]{64})\.asc", path): + return "immutable" + if re.fullmatch(r"dists/(ubuntu-24\.04|debian-13)/main/binary-amd64/by-hash/SHA256/[0-9a-f]{64}", path): + return "immutable" + if re.fullmatch(r"dists/(ubuntu-24\.04|debian-13)/(InRelease|Release|main/binary-amd64/Packages(?:\.gz)?)", path): + return "metadata" + raise PublicationError("inventory contains a path outside the APT protocol") + + +def plain_path(path, directory=False, missing=False): + """Reject symlinks in every ancestor, including deployment/output roots.""" + path = Path(path).absolute() + require(".." not in path.parts, "paths must not contain parent traversal") + for item in reversed((path, *path.parents)): + try: + mode = item.lstat().st_mode + except FileNotFoundError: + if missing: + continue + raise PublicationError("required path does not exist") from None + require(not stat.S_ISLNK(mode), "symlinks are forbidden in repository paths") + if item != path or directory: + require(stat.S_ISDIR(mode), "repository ancestor is not a directory") + return path + + +def tree_files(root): + plain_path(root, directory=True) + result = set() + for directory, dirs, files in os.walk(root, followlinks=False): + for name in dirs + files: + item = Path(directory) / name + info = item.lstat() + require(not stat.S_ISLNK(info.st_mode), "candidate contains a symlink") + if name in dirs: + require(stat.S_ISDIR(info.st_mode), "candidate contains a non-directory") + else: + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, + "candidate contains a nonregular file or hardlink") + result.add(item.relative_to(root).as_posix()) + return result + + +def inventory(root, fingerprint): + root = plain_path(root, directory=True) + actual = tree_files(root) + require(MANIFEST in actual, "candidate is missing its manifest") + manifest = read_json(root / MANIFEST) + require(isinstance(manifest, dict) and manifest.get("schemaVersion") == 1, + "unsupported repository manifest") + revision = manifest.get("revision") + require(isinstance(revision, str) and REVISION.fullmatch(revision), "invalid candidate revision") + unsigned = {key: value for key, value in manifest.items() if key != "revision"} + require(hashlib.sha256(canonical(unsigned)).hexdigest() == revision, + "candidate revision does not match its manifest") + require(manifest.get("signingFingerprint") == fingerprint, "candidate signing fingerprint differs") + require(isinstance(manifest.get("files"), list), "candidate inventory must be a list") + expected = {MANIFEST} + for entry in manifest["files"]: + require(isinstance(entry, dict), "invalid candidate file entry") + path = entry.get("path") + kind = classify(path) + require(path not in expected and entry.get("kind") == kind, + "duplicate file or incorrect inventory kind") + require(type(entry.get("size")) is int and entry["size"] >= 0, + "invalid inventory file size") + require(isinstance(entry.get("sha256"), str) and REVISION.fullmatch(entry["sha256"]), + "invalid inventory digest") + target = root / path + require(path in actual and target.stat().st_size == entry["size"] + and digest(target) == entry["sha256"], "candidate file checksum or size differs") + expected.add(path) + require(actual == expected, "candidate contains unlisted files") + require({suite.get("name") for suite in manifest.get("suites", [])} == set(SUITES), + "candidate must contain both supported suites") + for suite in SUITES: + for suffix in ("Release", "InRelease", "main/binary-amd64/Packages", "main/binary-amd64/Packages.gz"): + require(f"dists/{suite}/{suffix}" in expected, "candidate is missing required suite metadata") + require(f"keys/{fingerprint}.asc" in expected, "candidate is missing its public key") + return manifest + + +def verify_signed(root, key, fingerprint, historical=False): + manifest = inventory(root, fingerprint) + verifier = Path(__file__).resolve().with_name("apt-repository.py") + require(verifier.is_file(), "apt-repository.py verifier is missing") + command = [sys.executable, str(verifier), "verify", "--repository", str(root), + "--public-key", str(key), "--fingerprint", fingerprint] + if historical: + require(type(manifest.get("createdAt")) is int, "invalid repository creation time") + command += ["--now", str(manifest["createdAt"])] + result = subprocess.run(command, capture_output=True, text=True, check=False) + require(result.returncode == 0, "signed repository verification failed; run apt-repository.py verify for diagnostics") + return manifest + + +def fsync_directory(path): + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def make_directory(path, mode=0o755): + path = plain_path(path, directory=True, missing=True) + if path.exists(): + return + make_directory(path.parent, mode=mode) + path.mkdir(mode=mode) + descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW) + try: + # mkdir applies the SSH/workflow umask; set our intended mode only on + # newly created directories, preserving operator-provisioned ancestors. + os.fchmod(descriptor, mode) + os.fsync(descriptor) + finally: + os.close(descriptor) + fsync_directory(path.parent) + + +def atomic_write(target, source=None, data=None, mode=0o644, directory_mode=0o755): + plain_path(target, missing=True) + make_directory(target.parent, mode=directory_mode) + descriptor, temporary = tempfile.mkstemp(prefix=".upload-", dir=target.parent) + try: + with os.fdopen(descriptor, "wb") as output: + if source is not None: + with source.open("rb") as incoming: + shutil.copyfileobj(incoming, output, 1024 * 1024) + else: + output.write(data) + output.flush() + os.fchmod(output.fileno(), mode) + os.fsync(output.fileno()) + os.replace(temporary, target) + fsync_directory(target.parent) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def read_json(path): + plain_path(path) + try: + with path.open(encoding="utf-8") as source: + return json.load(source) + except (ValueError, UnicodeError) as error: + raise PublicationError("invalid repository JSON") from error + + +def root_path(value): + path = Path(value) + require(path.is_absolute() and path != Path("/"), "--root must be an absolute, non-root directory") + return plain_path(path, directory=True, missing=True) + + +@contextlib.contextmanager +def locked(root, create=False): + if create: + make_directory(root) + if not root.exists(): + raise EmptyRepository("repository has no committed snapshot") + lock = root / ".publish.lock" + plain_path(lock, missing=True) + if not create and not lock.exists(): + require(not (root / "state").exists() and not (root / "public").exists(), + "repository state exists without its publication lock") + raise EmptyRepository("repository has no committed snapshot") + descriptor = os.open(lock, os.O_NOFOLLOW | (os.O_RDWR | os.O_CREAT if create else os.O_RDONLY), 0o600) + try: + info = os.fstat(descriptor) + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, "invalid publication lock file") + try: + fcntl.flock(descriptor, (fcntl.LOCK_EX if create else fcntl.LOCK_SH) | fcntl.LOCK_NB) + except BlockingIOError: + raise PublicationError("repository is locked by another operation; retry later") from None + yield + finally: + os.close(descriptor) + + +def state(root, name): + path = root / "state" / f"{name}.json" + plain_path(path, missing=True) + if not path.exists(): + return None + record = read_json(path) + require(isinstance(record, dict) and isinstance(record.get("revision"), str) + and REVISION.fullmatch(record["revision"]), "invalid publication state") + return record + + +def _checkpoint(label): + """No-op hook for process-interruption tests; never controlled by environment.""" + + +def check_public(root, manifest, immutable_only=False): + for entry in manifest["files"]: + if immutable_only and entry["kind"] != "immutable": + continue + target = root / "public" / entry["path"] + plain_path(target, missing=True) + if target.exists(): + info = target.stat() + require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1, + "public target is not a standalone regular file") + require(info.st_size == entry["size"] and digest(target) == entry["sha256"], + "immutable URL collision" if immutable_only else "committed public repository has drifted") + elif not immutable_only: + raise PublicationError("committed public repository is missing files") + + +def save_snapshot(root, repository, manifest): + snapshots = root / "snapshots" + make_directory(snapshots, mode=0o700) + snapshot = snapshots / manifest["revision"] + plain_path(snapshot, directory=True, missing=True) + if snapshot.exists(): + require(inventory(snapshot, manifest["signingFingerprint"]) == manifest, + "retained snapshot differs from candidate") + return snapshot + temporary = Path(tempfile.mkdtemp(prefix=".staging-", dir=snapshots)) + try: + for entry in manifest["files"]: + atomic_write(temporary / entry["path"], source=repository / entry["path"], + mode=0o600, directory_mode=0o700) + atomic_write(temporary / MANIFEST, source=repository / MANIFEST, mode=0o600, directory_mode=0o700) + inventory(temporary, manifest["signingFingerprint"]) + fsync_directory(temporary) + os.replace(temporary, snapshot) + fsync_directory(snapshots) + finally: + if temporary.exists(): + shutil.rmtree(temporary) + return snapshot + + +def promote(root, snapshot, manifest): + """Resumable order: all immutable objects, metadata, per-suite InRelease.""" + check_public(root, manifest, immutable_only=True) + for entry in manifest["files"]: + if entry["kind"] == "immutable": + target = root / "public" / entry["path"] + if not target.exists(): + atomic_write(target, source=snapshot / entry["path"]) + _checkpoint("immutable") + for suite in SUITES: + prefix = f"dists/{suite}/" + for entry in manifest["files"]: + if entry["kind"] == "metadata" and entry["path"].startswith(prefix) and not entry["path"].endswith("/InRelease"): + atomic_write(root / "public" / entry["path"], source=snapshot / entry["path"]) + _checkpoint("metadata:" + suite) + relative = f"dists/{suite}/InRelease" + atomic_write(root / "public" / relative, source=snapshot / relative) + _checkpoint("committed:" + suite) + atomic_write(root / "public" / MANIFEST, source=snapshot / MANIFEST) + check_public(root, manifest) + atomic_write(root / "state" / "current.json", data=canonical({"revision": manifest["revision"]}) + b"\n", mode=0o600) + _checkpoint("current") + (root / "state" / "pending.json").unlink() + fsync_directory(root / "state") + return {"status": "published", "revision": manifest["revision"], "suites": list(SUITES)} + + +def publish_at(root, repository, fingerprint, expected): + manifest = inventory(repository, fingerprint) + with locked(root, create=True): + current = state(root, "current") + pending = state(root, "pending") + revision = current["revision"] if current else "empty" + if pending: + require(pending["revision"] == manifest["revision"], + "interrupted publication pending; recover it before publishing another revision") + require(expected == pending.get("previousRevision"), "expected revision differs from interrupted publication") + require(revision in (pending["previousRevision"], pending["revision"]), "current revision conflicts with pending journal") + snapshot = root / "snapshots" / pending["revision"] + require(inventory(snapshot, fingerprint) == manifest, "pending snapshot differs from candidate") + return promote(root, snapshot, manifest) + if revision == manifest["revision"]: + check_public(root, manifest) + return {"status": "unchanged", "revision": revision, "suites": list(SUITES)} + require(expected == revision, "expected revision differs from current publication (compare-and-swap failed)") + if current is None: + public = root / "public" + plain_path(public, directory=True, missing=True) + require(not public.exists() or not any(public.iterdir()), "refusing to adopt an unmanaged public repository") + check_public(root, manifest, immutable_only=True) + snapshot = save_snapshot(root, repository, manifest) + make_directory(root / "state", mode=0o700) + atomic_write(root / "state" / "pending.json", data=canonical({ + "revision": manifest["revision"], "previousRevision": revision, + }) + b"\n", mode=0o600) + _checkpoint("journal") + return promote(root, snapshot, manifest) + + +def recover_at(root, fingerprint, revision): + with locked(root, create=True): + pending = state(root, "pending") + require(pending is not None and pending["revision"] == revision, + "pending publication changed; fetch and verify it again before recovery") + current = state(root, "current") + require((current["revision"] if current else "empty") in (pending.get("previousRevision"), revision), + "current revision conflicts with pending journal") + snapshot = root / "snapshots" / revision + return promote(root, snapshot, inventory(snapshot, fingerprint)) + + +@contextlib.contextmanager +def selected_snapshot(root, fingerprint, revision=None, pending_only=False): + with locked(root): + pending = state(root, "pending") + if pending_only: + if not pending: + raise EmptyRepository("repository has no pending publication") + revision = pending["revision"] + else: + require(pending is None, "interrupted publication pending; recover before fetching snapshots") + if revision is None: + current = state(root, "current") + if not current: + raise EmptyRepository("repository has no committed snapshot") + revision = current["revision"] + snapshot = root / "snapshots" / revision + manifest = inventory(snapshot, fingerprint) + require(manifest["revision"] == revision, "snapshot does not match selected revision") + yield snapshot, manifest + + +def write_archive(repository, output): + with tarfile.open(fileobj=output, mode="w|") as archive: + for relative in sorted(tree_files(repository)): + archive.add(repository / relative, arcname=relative, recursive=False) + + +def read_archive(source, output): + seen = set() + with tarfile.open(fileobj=source, mode="r|*") as archive: + for member in archive: + safe_path(member.name) + require(member.name == MANIFEST or classify(member.name), "unexpected archive path") + require(member.isfile() and not member.issym() and not member.islnk() + and member.name not in seen, "archive contains a link, special file, or duplicate") + seen.add(member.name) + target = output / member.name + make_directory(target.parent) + with archive.extractfile(member) as incoming, target.open("xb") as destination: + shutil.copyfileobj(incoming, destination, 1024 * 1024) + + +def ssh_command(args, request): + require(re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.-]*@[A-Za-z0-9][A-Za-z0-9_.-]*", args.ssh), + "--ssh must be USER@HOST using an SSH alias, hostname, or IPv4 address") + command = ["ssh", "-T", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes", + "-o", "PasswordAuthentication=no", "-o", "KbdInteractiveAuthentication=no", + "-o", "ForwardAgent=no", "-o", "ClearAllForwardings=yes", "-o", "ConnectTimeout=15"] + if args.known_hosts: + key_file = plain_path(args.known_hosts) + require(key_file.is_file(), "--known-hosts must name a regular file") + command += ["-o", f"UserKnownHostsFile={key_file}"] + if args.ssh_port: + command += ["-p", str(args.ssh_port)] + if args.identity_file: + identity = plain_path(args.identity_file) + require(identity.is_file(), "--identity-file must name a regular file") + command += ["-i", str(identity), "-o", "IdentitiesOnly=yes"] + encoded = base64.urlsafe_b64encode(canonical(request)).decode("ascii") + source = Path(__file__).read_text(encoding="utf-8") + remote = "python3 -c " + shlex.quote(source) + " _serve " + shlex.quote(encoded) + return command + ["--", args.ssh, remote] + + +def remote_call(args, request, repository=None, output=None): + command = ssh_command(args, request) + with tempfile.TemporaryFile() as incoming, tempfile.TemporaryFile() as outgoing: + if repository: + write_archive(repository, incoming) + incoming.seek(0) + result = subprocess.run(command, stdin=incoming, stdout=outgoing, stderr=subprocess.PIPE, check=False) + if result.returncode == 3: + raise EmptyRepository("remote repository has no selected snapshot") + if result.returncode == 1: + # Forward only the server's deliberately sanitized structured error. + # SSH diagnostics can contain hostnames/paths and stay private. + try: + failure = json.loads(result.stderr) + if failure.get("status") == "error" and isinstance(failure.get("message"), str): + raise PublicationError(failure["message"]) + except (ValueError, AttributeError): + pass + require(result.returncode == 0, + "SSH repository operation failed; check trusted host keys, connectivity, remote Python, and publisher state") + outgoing.seek(0) + if output: + read_archive(outgoing, output) + return None + try: + return json.load(outgoing) + except (ValueError, UnicodeError) as error: + raise PublicationError("SSH repository response is not valid JSON") from error + + +def serve(request): + root = root_path(request["root"]) + fingerprint = request["fingerprint"] + require(FINGERPRINT.fullmatch(fingerprint), "invalid signing fingerprint") + action = request["action"] + if action == "publish": + require(request["expected"] == "empty" or REVISION.fullmatch(request["expected"]), "invalid expected revision") + with tempfile.TemporaryDirectory(prefix="loopwire-apt-upload-") as directory: + repository = Path(directory) + read_archive(sys.stdin.buffer, repository) + return publish_at(root, repository, fingerprint, request["expected"]) + if action == "recover": + require(REVISION.fullmatch(request["revision"]), "invalid recovery revision") + return recover_at(root, fingerprint, request["revision"]) + require(action in ("fetch", "fetch-pending"), "unknown remote operation") + revision = request.get("revision") + require(revision is None or REVISION.fullmatch(revision), "invalid fetch revision") + with selected_snapshot(root, fingerprint, revision, action == "fetch-pending") as (snapshot, _): + write_archive(snapshot, sys.stdout.buffer) + return None + + +def fetch_into(args, output, pending_only=False): + if args.ssh: + remote_call(args, {"action": "fetch-pending" if pending_only else "fetch", "root": args.root, + "fingerprint": args.fingerprint, "revision": getattr(args, "revision", None)}, output=output) + else: + with selected_snapshot(root_path(args.root), args.fingerprint, + getattr(args, "revision", None), pending_only) as (snapshot, _): + shutil.copytree(snapshot, output, dirs_exist_ok=True) + return verify_signed(output, args.public_key, args.fingerprint, + historical=not pending_only or getattr(args, "allow_expired", False)) + + +def parser(): + result = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + actions = result.add_subparsers(dest="action", required=True) + for name in ("publish", "fetch", "recover"): + action = actions.add_parser(name) + action.add_argument("--root", required=True, help="absolute origin root; HTTP serves ROOT/public") + action.add_argument("--public-key", required=True, type=Path) + action.add_argument("--fingerprint", required=True) + action.add_argument("--ssh", metavar="USER@HOST", help="omit for a local POSIX origin") + action.add_argument("--ssh-port", type=int) + action.add_argument("--identity-file", type=Path, help="existing SSH private key; alternatively use the caller's agent") + action.add_argument("--known-hosts", type=Path, help="pre-provisioned known_hosts; strict checking is mandatory") + if name == "publish": + action.add_argument("--repository", type=Path, required=True) + action.add_argument("--expected-revision", required=True, help="observed revision, or literal empty for first publication") + action.add_argument("--dry-run", action="store_true", help="verify locally; perform no origin access or upload") + elif name == "fetch": + action.add_argument("--output", type=Path, required=True, help="new destination directory (must not exist)") + action.add_argument("--revision", help="retained snapshot revision; defaults to committed current") + else: + action.add_argument("--dry-run", action="store_true", help="fetch and verify pending snapshot without promotion") + action.add_argument("--allow-expired", action="store_true", + help="finish an expired signed journal, then immediately fetch, re-sign, and publish fresh metadata") + return result + + +def run(args): + root_path(args.root) if not args.ssh else require(Path(args.root).is_absolute() and args.root != "/" + and ".." not in Path(args.root).parts, + "--root must be an absolute normalized non-root path") + require(FINGERPRINT.fullmatch(args.fingerprint), "--fingerprint must be a full uppercase fingerprint") + require(args.ssh_port is None or 1 <= args.ssh_port <= 65535, "invalid SSH port") + require(args.ssh or (args.ssh_port is None and args.known_hosts is None and args.identity_file is None), + "SSH options require --ssh") + args.public_key = plain_path(args.public_key) + require(args.public_key.is_file(), "--public-key must name a file") + if args.action == "publish": + require(args.expected_revision == "empty" or REVISION.fullmatch(args.expected_revision), "invalid expected revision") + manifest = verify_signed(args.repository, args.public_key, args.fingerprint) + if args.dry_run: + return {"status": "validated", "revision": manifest["revision"], "originChecked": False} + with tempfile.TemporaryDirectory(prefix="loopwire-apt-candidate-") as directory: + candidate = Path(directory) / "repository" + shutil.copytree(args.repository, candidate, symlinks=True) + require(verify_signed(candidate, args.public_key, args.fingerprint) == manifest, + "candidate changed during verification") + if args.ssh: + return remote_call(args, {"action": "publish", "root": args.root, "fingerprint": args.fingerprint, + "expected": args.expected_revision}, repository=candidate) + return publish_at(root_path(args.root), candidate, args.fingerprint, args.expected_revision) + if args.action == "fetch": + require(args.revision is None or REVISION.fullmatch(args.revision), "invalid selected revision") + output = plain_path(args.output, directory=True, missing=True) + require(not output.exists(), "--output must not exist") + require(output.parent.is_dir(), "--output parent must already exist") + with tempfile.TemporaryDirectory(prefix=".loopwire-apt-fetch-", dir=output.parent) as directory: + fetched = Path(directory) / "repository" + fetched.mkdir() + manifest = fetch_into(args, fetched) + os.rename(fetched, output) + fsync_directory(output.parent) + return {"status": "fetched", "revision": manifest["revision"]} + with tempfile.TemporaryDirectory(prefix="loopwire-apt-recovery-") as directory: + manifest = fetch_into(args, Path(directory), pending_only=True) + needs_refresh = manifest["validUntil"] <= int(time.time()) + if args.dry_run: + return {"status": "recovery-validated", "revision": manifest["revision"], "requiresRefresh": needs_refresh} + if args.ssh: + result = remote_call(args, {"action": "recover", "root": args.root, "fingerprint": args.fingerprint, + "revision": manifest["revision"]}) + else: + result = recover_at(root_path(args.root), args.fingerprint, manifest["revision"]) + result["requiresRefresh"] = needs_refresh + if needs_refresh: + result["nextAction"] = "Immediately fetch, re-sign, and publish fresh metadata; APT rejects the expired snapshot." + return result + + +def main(): + try: + if len(sys.argv) == 3 and sys.argv[1] == "_serve": + result = serve(json.loads(base64.urlsafe_b64decode(sys.argv[2]))) + else: + result = run(parser().parse_args()) + if result is not None: + print(json.dumps(result, sort_keys=True)) + return 0 + except EmptyRepository: + print(json.dumps({"status": "empty", "revision": None})) + return 3 + except (PublicationError, OSError, ValueError, KeyError, TypeError, tarfile.TarError) as error: + # OS/transport exceptions can include machine-local paths; do not print them. + message = str(error) if isinstance(error, PublicationError) else "repository operation failed; check filesystem and inputs" + print(json.dumps({"status": "error", "message": message}), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/setup-apt-repository.sh b/scripts/setup-apt-repository.sh new file mode 100755 index 0000000..6b1a9bb --- /dev/null +++ b/scripts/setup-apt-repository.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail + +base_url="" +fingerprint="" +install_root="/" +remove="false" +dry_run="false" + +fail() { printf 'setup-apt-repository: %s\n' "$*" >&2; exit 1; } +usage() { + cat <<'USAGE' +Configure Loopwire's signed APT repository on Ubuntu 24.04 or Debian 13 (amd64). + +Usage: + sudo bash setup-apt-repository.sh --base-url HTTPS_URL --fingerprint OPENPGP_FINGERPRINT + sudo bash setup-apt-repository.sh --remove + bash setup-apt-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 dpkg. Existing unrelated APT sources are preserved. +This only writes the source/keyring configuration. Run apt update and apt install yourself afterward. +USAGE +} +while [ "$#" -gt 0 ]; do + case "$1" in + --base-url) base_url="${2:?missing --base-url value}"; shift 2 ;; + --fingerprint) fingerprint="${2:?missing --fingerprint value}"; shift 2 ;; + --root) install_root="${2:?missing --root value}"; shift 2 ;; + --remove) remove="true"; shift ;; + --dry-run) dry_run="true"; shift ;; + -h|--help) usage; exit 0 ;; + *) fail "unknown option: $1" ;; + esac +done + +install_root="$(realpath -e "$install_root")" +[ -d "$install_root" ] || fail "root must be an existing directory" +source_file="${install_root%/}/etc/apt/sources.list.d/loopwire.sources" +key_directory="${install_root%/}/etc/apt/keyrings" +python3 - "$install_root" "$source_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-apt-repository: refusing symbolic links inside the target APT configuration tree') + if not part.is_relative_to(root): + sys.exit('setup-apt-repository: APT configuration path leaves the target root') +PY +owner_marker="# Managed by Loopwire APT repository setup" +[ ! -L "$source_file" ] || fail "refusing a symbolic-link source file" +if [ -e "$source_file" ] && ! head -n 1 "$source_file" | grep -Fxq "$owner_marker"; then + fail "loopwire.sources 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 /etc/apt, or use --dry-run" +fi + +if [ "$remove" = true ]; then + if [ "$dry_run" = true ]; then + printf 'Would remove the managed Loopwire APT source and its keyring.\n' + exit 0 + fi + if [ -f "$source_file" ]; then + key_name="$(sed -n 's|^Signed-By: /etc/apt/keyrings/\(loopwire-[A-F0-9]*\.asc\)$|\1|p' "$source_file")" + [[ "$key_name" =~ ^loopwire-[A-F0-9]{40}\.asc$ ]] || fail "managed source has an unexpected keyring path" + rm -- "$source_file" + rm -f -- "$key_directory/$key_name" + fi + printf 'Loopwire APT source removed. Installed packages and other sources are unchanged.\n' + exit 0 +fi + +for command in curl gpg python3 dpkg; 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-apt-repository: base URL must be HTTPS without credentials, whitespace, query, or fragment') +print(value.rstrip('/')) +PY +)" + +# Read os-release as data; do not execute a file supplied through --root. +suite="$(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] +suite = {('ubuntu', '24.04'): 'ubuntu-24.04', ('debian', '13'): 'debian-13'}.get( + (values.get('ID'), values.get('VERSION_ID'))) +if not suite: + sys.exit('setup-apt-repository: supported systems are Ubuntu 24.04 and Debian 13') +print(suite) +PY +)" +[ "$(dpkg --print-architecture)" = amd64 ] || fail "this channel currently supports amd64 only" +key_name="loopwire-${fingerprint}.asc" +[ ! -L "$key_directory/$key_name" ] || fail "refusing a symbolic-link keyring" +if [ "$dry_run" = true ]; then + printf 'Would verify %s/keys/%s.asc and configure suite %s with a scoped Signed-By keyring.\n' \ + "$base_url" "$fingerprint" "$suite" + exit 0 +fi + +temporary="$(mktemp -d)" +key_temporary="" +source_temporary="" +cleanup() { + rm -rf -- "$temporary" + [ -z "$key_temporary" ] || rm -f -- "$key_temporary" + [ -z "$source_temporary" ] || rm -f -- "$source_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.sources" <", "rsa2048", "sign", "1d") + keys = run("gpg", "--homedir", str(cls.home), "--with-colons", "--list-keys").stdout + cls.fingerprint = next(line.split(":")[9] for line in keys.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) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.start() + cls.url = f"https://127.0.0.1:{cls.server.server_port}" + cls.binary = cls.work / "bin" + cls.binary.mkdir() + dpkg = cls.binary / "dpkg" + dpkg.write_text('#!/bin/sh\nprintf "%s\\n" "${TEST_ARCH:-amd64}"\n') + dpkg.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.os_release("ubuntu", "24.04") + self.source = self.root / "etc/apt/sources.list.d/loopwire.sources" + self.key = self.root / f"etc/apt/keyrings/loopwire-{self.fingerprint}.asc" + self.requests.clear() + + def os_release(self, identity, version): + (self.root / "etc/os-release").write_text(f'ID={identity}\nVERSION_ID="{version}"\n') + + 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_supported_suites_and_idempotence(self): + for identity, version, suite in [("ubuntu", "24.04", "ubuntu-24.04"), ("debian", "13", "debian-13")]: + with self.subTest(suite=suite): + self.os_release(identity, version) + result = self.invoke() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn(f"Suites: {suite}\n", self.source.read_text()) + self.assertIn(f"Signed-By: /etc/apt/keyrings/loopwire-{self.fingerprint}.asc", self.source.read_text()) + self.assertEqual(self.key.read_text(), self.public) + self.assertEqual(self.key.stat().st_mode & 0o777, 0o644) + source_bytes = self.source.read_bytes() + again = self.invoke() + self.assertEqual(again.returncode, 0, again.stderr) + self.assertEqual(self.source.read_bytes(), source_bytes) + + 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.source.exists()) + self.assertFalse(self.key.exists()) + + def test_wrong_fingerprint_preserves_existing_configuration(self): + self.assertEqual(self.invoke().returncode, 0) + before = self.source.read_bytes() + result = self.invoke(fingerprint="A" * 40) + self.assertNotEqual(result.returncode, 0) + self.assertIn("does not match", result.stderr) + self.assertEqual(self.source.read_bytes(), before) + self.assertEqual(self.key.read_text(), self.public) + + def test_tls_authentication_required(self): + result = self.invoke(env={"CURL_CA_BUNDLE": str(self.work / "absent-ca.pem")}) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(self.source.exists()) + + def test_bad_urls_rejected_without_network(self): + for url in ["http://example.invalid", "https://user:pass@example.invalid", "https://example.invalid/#fragment", + "https://example.invalid/?query=x", "https://example.invalid/\ninjected", "https://example.invalid:99999"]: + with self.subTest(url=url): + self.assertNotEqual(self.invoke(url=url).returncode, 0) + self.assertEqual(self.requests, []) + self.assertFalse(self.source.exists()) + + def test_unsupported_distribution_and_architecture(self): + self.os_release("ubuntu", "22.04") + self.assertNotEqual(self.invoke().returncode, 0) + self.os_release("ubuntu", "24.04") + self.assertNotEqual(self.invoke(env={"TEST_ARCH": "arm64"}).returncode, 0) + self.assertEqual(self.requests, []) + + def test_os_release_is_never_executed(self): + sentinel = self.work / "must-not-exist" + self.os_release(f'"$(touch {sentinel})"', "24.04") + self.assertNotEqual(self.invoke().returncode, 0) + self.assertFalse(sentinel.exists()) + + def test_unmanaged_source_preserved(self): + self.source.parent.mkdir(parents=True) + self.source.write_text("# Maintainer-owned source\n") + self.assertNotEqual(self.invoke().returncode, 0) + self.assertNotEqual(self.invoke("--remove").returncode, 0) + self.assertEqual(self.source.read_text(), "# Maintainer-owned source\n") + + def test_source_symlink_rejected(self): + self.source.parent.mkdir(parents=True) + destination = self.root / "unrelated" + destination.write_text("keep") + self.source.symlink_to(destination) + self.assertNotEqual(self.invoke().returncode, 0) + self.assertEqual(destination.read_text(), "keep") + + def test_symlinked_parent_cannot_escape_offline_root(self): + outside = self.work / "outside" + outside.mkdir(exist_ok=True) + for relative in ["etc/apt", "etc/apt/sources.list.d", "etc/apt/keyrings"]: + with self.subTest(relative=relative): + shutil.rmtree(self.root / "etc/apt", ignore_errors=True) + parent = self.root / relative + parent.parent.mkdir(parents=True, exist_ok=True) + parent.symlink_to(outside, target_is_directory=True) + try: + result = self.invoke() + self.assertNotEqual(result.returncode, 0) + self.assertEqual(list(outside.iterdir()), []) + finally: + parent.unlink(missing_ok=True) + shutil.rmtree(outside) + outside.mkdir() + + def test_remove_is_idempotent_and_preserves_other_sources(self): + self.assertEqual(self.invoke().returncode, 0) + other = self.source.with_name("unrelated.sources") + other.write_text("keep") + for _ in range(2): + result = self.invoke("--remove") + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(self.source.exists()) + self.assertFalse(self.key.exists()) + self.assertEqual(other.read_text(), "keep") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-apt-public.py b/scripts/test-apt-public.py new file mode 100755 index 0000000..0e0196c --- /dev/null +++ b/scripts/test-apt-public.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Test public byte verification and activation output independently of the signed-chain verifier.""" +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-apt-public.py") +spec = importlib.util.spec_from_file_location("apt_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-apt-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) + cls.requests = [] + + class Handler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_GET(self): + cls.requests.append(self.path) + if self.path.startswith("/redirect/"): + self.send_response(302) + self.send_header("Location", "/must-not-follow") + self.end_headers() + else: + 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(cls.cert, key) + cls.server.socket = context.wrap_socket(cls.server.socket, server_side=True) + cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True) + cls.thread.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): + self.requests.clear() + (self.web / "payload").write_bytes(b"expected package bytes") + manifest = json.dumps({ + "revision": "a" * 64, + "files": [{"path": "payload", "size": 22, "sha256": hashlib.sha256(b"expected package bytes").hexdigest()}], + }) + (self.root / "repository-manifest.json").write_text(manifest) + (self.web / "repository-manifest.json").write_text(manifest) + self.output = self.root / "activation.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 / "trusted.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 verify, \ + patch.object(module.ssl, "create_default_context", return_value=trust), \ + contextlib.redirect_stdout(io.StringIO()) as output: + if verifier_error: + verify.side_effect = verifier_error + module.main() + verify.assert_called_once() + self.assertTrue(verify.call_args.kwargs["check"]) + self.assertIn("--fingerprint", verify.call_args.args[0]) + self.assertIn(str(self.root / "trusted.asc"), verify.call_args.args[0]) + return json.loads(output.getvalue()) + + def test_verified_bytes_produce_activation_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["status"], "verified") + self.assertEqual(record["baseUrl"], self.url) + self.assertEqual(record["revision"], "a" * 64) + self.assertEqual(record["signingFingerprint"], "A" * 40) + self.assertIn("verifiedAt", record) + + def test_remote_tamper_never_overwrites_activation(self): + self.output.write_text("previous activation") + (self.web / "payload").write_bytes(b"changed package bytes!") + with self.assertRaises(ValueError): + self.invoke("--output", str(self.output), "--proof-url", "https://github.com/sandwichfarm/loopwire/actions/runs/123") + self.assertEqual(self.output.read_text(), "previous activation") + + def test_public_manifest_tamper_is_rejected(self): + (self.web / "repository-manifest.json").write_text("{}") + with self.assertRaisesRegex(ValueError, "repository-manifest.json"): + self.invoke() + + def test_missing_file_fails(self): + (self.web / "payload").unlink() + with self.assertRaisesRegex(ValueError, "HTTP 404"): + self.invoke() + self.assertFalse(self.output.exists()) + + def test_redirect_is_rejected(self): + with self.assertRaisesRegex(ValueError, "does not follow redirects"): + self.invoke("--base-url", self.url + "/redirect") + self.assertEqual(self.requests, ["/redirect/payload"]) + + def test_invalid_local_signature_prevents_network(self): + with self.assertRaises(subprocess.CalledProcessError): + self.invoke(verifier_error=subprocess.CalledProcessError(1, "signed verifier")) + self.assertEqual(self.requests, []) + + def test_activation_requires_workflow_evidence_url(self): + for url in ["", "https://example.invalid/run/123", "https://github.com/test/repo/actions/runs/not-a-number"]: + with self.subTest(url=url), self.assertRaisesRegex(ValueError, "GitHub Actions"): + self.invoke("--output", str(self.output), "--proof-url", url) + self.assertEqual(self.requests, []) + + def test_custom_ca_fixture_cannot_produce_public_activation(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") + self.assertFalse(self.output.exists()) + self.assertEqual(self.requests, []) + + def test_https_and_independent_fingerprint_required(self): + for args in [("--base-url", "http://example.invalid"), ("--base-url", "https://user:pass@example.invalid"), + ("--fingerprint", "A" * 8)]: + with self.subTest(args=args), self.assertRaises(ValueError): + self.invoke(*args) + self.assertEqual(self.requests, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-apt-repository-vm-proof.mjs b/scripts/test-apt-repository-vm-proof.mjs new file mode 100644 index 0000000..050c1ea --- /dev/null +++ b/scripts/test-apt-repository-vm-proof.mjs @@ -0,0 +1,95 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { debPayload, parseInstalledHashes, verifyInstalledStage, verifyLifecycle } from "./verify-apt-repository-vm-proof.mjs"; + +const directory = await mkdtemp(path.join(tmpdir(), "loopwire-apt-proof-test-")); +const baseline = "0.1.0-1ubuntu24.04"; +const upgrade = "0.1.0+aptfixture1-1ubuntu24.04"; +const baseUrl = "https://127.0.0.1:8443"; +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 transitions = [ + `install\t${baseline}\tinstalled`, `reinstall\t${baseline}\tinstalled`, `upgrade\t${upgrade}\tinstalled`, + `rollback\t${baseline}\tinstalled`, `remove\t${baseline}\tabsent`, +].join("\n"); +let passed = 0; +async function test(name, action) { + await action(); + passed += 1; + console.log(`PASS ${name}`); +} +async function stageFixture() { + await mkdir(path.join(directory, "install"), { recursive: true }); + const files = { + "package-metadata.tsv": `loopwire\t${baseline}\tamd64\tinstall ok installed\n`, + "package-files.txt": `${Object.keys(expectedHashes).join("\n")}\n`, + "installed-files.sha256": `${Object.entries(expectedHashes).map(([name, hash]) => `${hash} ${name}`).join("\n")}\n`, + "apt-policy.txt": `loopwire:\n Installed: ${baseline}\n Candidate: ${baseline}\n 500 ${baseUrl} ubuntu-24.04/main amd64 Packages\n`, + "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 => /lib/libgtk-3.so\nlibwebkit2gtk-4.1.so => /lib/libwebkit2gtk-4.1.so\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, baseUrl, expectedHashes); +} +async function change(name, transform) { + const file = path.join(directory, "install", name); + await writeFile(file, transform(await readFile(file, "utf8"))); +} +try { + await test("Zstandard deb payloads are read through dpkg-deb", async () => { + const packageRoot = path.join(directory, "zstd-package"); + await mkdir(path.join(packageRoot, "DEBIAN"), { recursive: true }); + await mkdir(path.join(packageRoot, "usr/bin"), { recursive: true }); + await writeFile(path.join(packageRoot, "DEBIAN/control"), + "Package: loopwire\nVersion: 0.1.0-1ubuntu24.04\nArchitecture: amd64\nMaintainer: Test \nDescription: fixture\n"); + await writeFile(path.join(packageRoot, "usr/bin/loopwire"), "#!/bin/sh\necho fixture\n"); + await chmod(path.join(packageRoot, "usr/bin/loopwire"), 0o755); + const packageFile = path.join(directory, "loopwire-zstd.deb"); + const built = spawnSync("dpkg-deb", ["-Zzstd", "--root-owner-group", "--build", packageRoot, packageFile], + { encoding: "utf8" }); + assert.equal(built.status, 0, built.stderr); + assert.deepEqual(debPayload(packageFile), { + "/usr/bin/loopwire": "6ecf06f6dbbab6a920b5b208bc7c4069ca266b150d6c00533a00b5975a8417ca" + }); + }); + await test("complete ordered lifecycle accepted", () => verifyLifecycle(transitions, baseline, upgrade)); + await test("missing reinstall rejected", () => assert.throws(() => verifyLifecycle(transitions.split("\n").filter((line) => !line.startsWith("reinstall")).join("\n"), baseline, upgrade), /lifecycle transitions/)); + await test("rollback remaining at new version rejected", () => assert.throws(() => verifyLifecycle(transitions.replace(`rollback\t${baseline}`, `rollback\t${upgrade}`), baseline, upgrade), /lifecycle transitions/)); + await test("fabricated summary pass is insufficient", () => assert.throws(() => verifyLifecycle("pass", baseline, upgrade), /lifecycle transitions/)); + await test("duplicate file hash rejected", () => assert.throws(() => parseInstalledHashes(`${"a".repeat(64)} /usr/bin/loopwire\n${"b".repeat(64)} /usr/bin/loopwire\n`), /duplicate/)); + await test("parent traversal hash rejected", () => assert.throws(() => parseInstalledHashes(`${"a".repeat(64)} /usr/../etc/passwd\n`), /invalid/)); + await stageFixture(); + await test("complete stage fixture accepted", verifyStage); + for (const [name, file, mutation, pattern] of [ + ["changed installed bytes rejected", "installed-files.sha256", (value) => value.replace("a".repeat(64), "b".repeat(64)), /signed package payload/], + ["wrong installed version rejected", "package-metadata.tsv", (value) => value.replace(baseline, upgrade), /package metadata/], + ["local file installation without origin rejected", "apt-policy.txt", (value) => value.replace(baseUrl, "file:\/tmp/local"), /repository origin/], + ["unresolved GUI dependency rejected", "gui-ldd.txt", (value) => `${value}libmissing.so => not found\n`, /linkage/], + ["unrelated X11 window rejected", "gui-window-names.txt", () => "xterm\n", /application window/], + ["failed GUI process rejected", "gui-launch-status.txt", () => "124\n", /GUI launch/], + ["GUI panic rejected", "gui-launch.log", () => "thread main panicked\n", /fatal GUI log/], + ["empty provider output rejected", "dsp-provider-help.txt", () => "", /empty evidence/], + ["missing installed helper rejected", "package-files.txt", (value) => value.replace("/usr/bin/loopwire-dsp-provider\n", ""), /missing/], + ]) { + await stageFixture(); + await change(file, mutation); + await test(name, () => assert.rejects(verifyStage, pattern)); + } + console.log(`APT VM proof verifier tests passed: ${passed}`); +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/scripts/test-apt-repository.py b/scripts/test-apt-repository.py new file mode 100644 index 0000000..a264d9e --- /dev/null +++ b/scripts/test-apt-repository.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +"""Credential-free APT repository regression tests using real dpkg, GPG and APT. + +Run on Ubuntu 24.04 or Debian 13 with python3, dpkg-dev, apt-utils, gnupg, +and openssl installed. All keys, package fixtures, servers and APT state are +temporary; the host's sources, trusted keyrings and package database are unused. +""" + +import functools +import hashlib +import http.server +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import threading +import time +import unittest + + +SCRIPT = Path(__file__).with_name("apt-repository.py") +SUITES = {"ubuntu-24.04": "ubuntu24.04", "debian-13": "debian13"} + + +def run(*args, ok=True, **kwargs): + result = subprocess.run([str(arg) for arg in args], capture_output=True, text=True, **kwargs) + if ok and result.returncode: + raise AssertionError(f"{args!r}\n{result.stdout}\n{result.stderr}") + return result + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +class QuietHandler(http.server.SimpleHTTPRequestHandler): + def log_message(self, *_args): + pass + + +class RepositoryTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + for tool in ("dpkg-deb", "dpkg", "gpg", "gpgv", "apt-get", "openssl"): + if not shutil.which(tool): + raise RuntimeError(f"{tool} required; run these tests in an Ubuntu/Debian container") + cls.temporary = tempfile.TemporaryDirectory(prefix="loopwire-apt-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 Test", "Wrong Test"): + run("gpg", "--homedir", cls.gnupg, "--batch", "--pinentry-mode", "loopback", + "--passphrase", "", "--quick-generate-key", identity, "rsa2048", "sign", "1d") + 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 / "archive.asc" + cls.key.write_text(run("gpg", "--homedir", cls.gnupg, "--armor", "--export", + cls.fingerprint).stdout) + cls.release_private = cls.root / "release-private.pem" + cls.release_public = cls.root / "release-public.pem" + run("openssl", "genpkey", "-algorithm", "RSA", "-pkeyopt", "rsa_keygen_bits:2048", + "-out", cls.release_private) + run("openssl", "pkey", "-in", cls.release_private, "-pubout", "-out", cls.release_public) + cls.release1 = cls.make_release("1.0.0") + cls.release2 = cls.make_release("1.1.0") + cls.date = int(time.time()) + cls.base = cls.root / "base" + cls.build(cls.release1, "1.0.0", cls.base) + + @classmethod + def tearDownClass(cls): + run("gpgconf", "--homedir", cls.gnupg, "--kill", "gpg-agent", ok=False) + cls.temporary.cleanup() + + @classmethod + def make_release(cls, version, architecture="amd64", package_name="loopwire", suffix=""): + release = cls.root / f"release-{version}-{architecture}-{package_name}{suffix}" + release.mkdir() + for suite, revision in SUITES.items(): + package_root = cls.root / f"pkg-{version}-{suite}-{architecture}-{package_name}{suffix}" + (package_root / "DEBIAN").mkdir(parents=True) + (package_root / "usr/share/loopwire").mkdir(parents=True) + (package_root / "usr/share/loopwire/fixture").write_text(version + suffix) + (package_root / "DEBIAN/control").write_text( + f"Package: {package_name}\nVersion: {version}-1{revision}\n" + f"Architecture: {architecture}\nMaintainer: Test \n" + "Description: Loopwire repository fixture\n A multiline description.\n") + output = release / f"loopwire_{version}-1{revision}_amd64.deb" + run("dpkg-deb", "--root-owner-group", "--build", package_root, output) + cls.sign_release(release) + return release + + @classmethod + def sign_release(cls, release): + (release / "SHA256SUMS").write_text("".join( + f"{digest(path)} {path.name}\n" for path in sorted(release.glob("*.deb")))) + run("openssl", "dgst", "-sha256", "-sign", cls.release_private, + "-out", release / "SHA256SUMS.sig", release / "SHA256SUMS") + + @classmethod + def build(cls, release, version, output, *extra, ok=True): + return run(sys.executable, SCRIPT, "build", "--release-dir", release, "--version", version, + "--output", output, "--signing-key", cls.fingerprint, "--gnupg-home", cls.gnupg, + "--release-public-key", cls.release_public, "--date", cls.date, *extra, ok=ok) + + def setUp(self): + self.case_dir = self.root / self.id().split(".")[-1] + self.case_dir.mkdir(mode=0o755) + self.repo = self.case_dir / "repository" + shutil.copytree(self.base, self.repo) + + def verify(self, *extra, ok=True): + return run(sys.executable, SCRIPT, "verify", "--repository", self.repo, + "--public-key", self.key, "--fingerprint", self.fingerprint, *extra, ok=ok) + + def rewrite_manifest(self): + manifest_path = self.repo / "repository-manifest.json" + manifest = json.loads(manifest_path.read_text()) + for entry in manifest["files"]: + path = self.repo / entry["path"] + if path.is_file(): + entry.update(sha256=digest(path), size=path.stat().st_size) + manifest.pop("revision", None) + encoded = json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode() + manifest["revision"] = hashlib.sha256(encoded).hexdigest() + manifest_path.write_text(json.dumps(manifest)) + + def apt(self, suite="ubuntu-24.04", operation=("update",), key=None): + handler = functools.partial(QuietHandler, directory=str(self.repo)) + server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + state = self.case_dir / "apt" + state.mkdir(exist_ok=True) + state.chmod(0o755) + for directory in ("lists", "cache", "downloads"): + (state / directory).mkdir(exist_ok=True) + (state / directory).chmod(0o777) + source = state / "loopwire.sources" + source.write_text( + f"Types: deb\nURIs: http://127.0.0.1:{server.server_port}\nSuites: {suite}\n" + f"Components: main\nArchitectures: amd64\nSigned-By: {key or self.key}\nBy-Hash: force\n") + args = ["apt-get", "-o", f"Dir::Etc::sourcelist={source}", "-o", "Dir::Etc::sourceparts=-", + "-o", f"Dir::State::lists={state / 'lists'}", "-o", f"Dir::Cache={state / 'cache'}", + "-o", "Dir::State::status=/dev/null", "-o", "APT::Architecture=amd64", + "-o", "APT::Architectures::=amd64", "-o", "Acquire::Languages=none", + "-o", "APT::Update::Error-Mode=any"] + try: + update = run(*args, "update", ok=False, cwd=state / "downloads") + if operation == ("update",) or update.returncode: + return update + return run(*args, *operation, ok=False, cwd=state / "downloads") + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_signed_chain_and_real_apt_download_both_suites(self): + summary = json.loads(self.verify().stdout) + self.assertEqual(summary["signingFingerprint"], self.fingerprint) + for suite, revision in SUITES.items(): + result = self.apt(suite, ("download", f"loopwire=1.0.0-1{revision}")) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + downloaded = self.case_dir / "apt/downloads" / f"loopwire_1.0.0-1{revision}_amd64.deb" + self.assertEqual(digest(downloaded), digest(self.release1 / downloaded.name)) + + def test_retention_version_order_and_fresh_rollback(self): + upgraded = self.case_dir / "upgraded" + self.build(self.release2, "1.1.0", upgraded, "--previous", self.base) + old = json.loads((self.base / "repository-manifest.json").read_text()) + new = json.loads((upgraded / "repository-manifest.json").read_text()) + for entry in old["files"]: + if entry["kind"] == "immutable": + self.assertEqual(digest(upgraded / entry["path"]), entry["sha256"]) + for suite in new["suites"]: + self.assertEqual(len(suite["packages"]), 2) + failed = self.build(self.release1, "1.0.0", self.case_dir / "downgrade", + "--previous", upgraded, ok=False) + self.assertNotEqual(failed.returncode, 0) + rollback = self.case_dir / "rollback" + run(sys.executable, SCRIPT, "rollback", "--repository", self.base, "--output", rollback, + "--signing-key", self.fingerprint, "--gnupg-home", self.gnupg, + "--date", self.date + 60) + rolled = json.loads((rollback / "repository-manifest.json").read_text()) + self.assertEqual(rolled["suites"], old["suites"]) + self.assertGreater(rolled["createdAt"], old["createdAt"]) + self.assertNotEqual(rolled["revision"], old["revision"]) + run(sys.executable, SCRIPT, "verify", "--repository", rollback, "--public-key", self.key, + "--fingerprint", self.fingerprint, "--now", self.date + 60) + + def test_release_input_signature_and_duplicate_checksum_rejected(self): + release = self.case_dir / "release" + shutil.copytree(self.release1, release) + (release / "SHA256SUMS.sig").write_bytes(b"invalid") + self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / "bad", ok=False).returncode, 0) + self.sign_release(release) + checksums = release / "SHA256SUMS" + checksums.write_text(checksums.read_text() * 2) + run("openssl", "dgst", "-sha256", "-sign", self.release_private, + "-out", release / "SHA256SUMS.sig", checksums) + self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / "duplicate", ok=False).returncode, 0) + self.sign_release(release) + shutil.copyfile(next(release.glob("*.deb")), release / "duplicate.deb") + self.sign_release(release) + self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / "extra-deb", ok=False).returncode, 0) + + def test_reproducible_signed_candidate_and_build_metadata_upgrade(self): + second = self.case_dir / "second" + self.build(self.release1, "1.0.0", second) + self.assertEqual((second / "repository-manifest.json").read_bytes(), + (self.base / "repository-manifest.json").read_bytes()) + release = self.make_release("1.0.0+aptfixture1") + upgraded = self.case_dir / "upgraded" + self.build(release, "1.0.0+aptfixture1", upgraded, "--previous", self.base) + self.repo = upgraded + result = self.apt(operation=("download", "loopwire")) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertTrue((self.case_dir / "apt/downloads/loopwire_1.0.0+aptfixture1-1ubuntu24.04_amd64.deb").is_file()) + + def test_encrypted_signing_key_uses_passphrase_file(self): + # GnuPG's Unix socket pathname must fit the platform's short limit. + home = self.root / "encrypted-gnupg" + home.mkdir(mode=0o700) + passphrase = self.case_dir / "passphrase" + passphrase.write_text("fixture passphrase with spaces\n") + passphrase.chmod(0o600) + try: + run("gpg", "--homedir", home, "--batch", "--pinentry-mode", "loopback", "--passphrase-file", + passphrase, "--quick-generate-key", "Encrypted Fixture", "rsa2048", "sign", "1d") + listing = run("gpg", "--homedir", home, "--with-colons", "--list-keys").stdout + identity = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:")) + run("gpgconf", "--homedir", home, "--kill", "gpg-agent") + output = self.case_dir / "encrypted" + self.build(self.release1, "1.0.0", output, "--gnupg-home", home, "--signing-key", identity, + "--passphrase-file", passphrase, "--date", int(time.time())) + run(sys.executable, SCRIPT, "verify", "--repository", output, "--fingerprint", identity, + "--public-key", output / f"keys/{identity}.asc") + finally: + run("gpgconf", "--homedir", home, "--kill", "gpg-agent", ok=False) + + def test_package_architecture_name_and_version_rejected(self): + for architecture, name in (("arm64", "loopwire"), ("amd64", "other")): + release = self.make_release("1.2.0", architecture, name) + self.assertNotEqual(self.build(release, "1.2.0", self.case_dir / name / architecture, + ok=False).returncode, 0) + self.assertNotEqual(self.build(self.release1, "1.0.0-rc.1", self.case_dir / "prerelease", + ok=False).returncode, 0) + release = self.case_dir / "mismatched-suite" + shutil.copytree(self.release1, release) + ubuntu = release / "loopwire_1.0.0-1ubuntu24.04_amd64.deb" + debian = release / "loopwire_1.0.0-1debian13_amd64.deb" + shutil.copyfile(ubuntu, debian) + self.sign_release(release) + self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / "bad-suite", ok=False).returncode, 0) + + def test_wrong_signer_rejected(self): + self.assertNotEqual(self.verify("--fingerprint", self.wrong_fingerprint, ok=False).returncode, 0) + wrong_key = self.case_dir / "wrong.asc" + wrong_key.write_text(run("gpg", "--homedir", self.gnupg, "--armor", "--export", + self.wrong_fingerprint).stdout) + self.assertNotEqual(self.apt(key=wrong_key).returncode, 0) + + def test_signed_metadata_tampering_and_unsigned_repository_rejected(self): + inrelease = self.repo / "dists/ubuntu-24.04/InRelease" + inrelease.write_text(inrelease.read_text().replace("Origin: Loopwire", "Origin: Forged!!")) + self.rewrite_manifest() + self.assertNotEqual(self.verify(ok=False).returncode, 0) + self.assertNotEqual(self.apt().returncode, 0) + inrelease.unlink() + self.assertNotEqual(self.apt().returncode, 0) + + def test_modified_package_rejected_by_verifier_and_apt(self): + package = next(self.repo.glob("pool/ubuntu-24.04/**/*.deb")) + package.write_bytes(package.read_bytes() + b"tampered") + self.rewrite_manifest() + self.assertNotEqual(self.verify(ok=False).returncode, 0) + result = self.apt(operation=("download", "loopwire=1.0.0-1ubuntu24.04")) + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + + def test_tampered_index_and_previous_snapshot_rejected(self): + index = self.repo / "dists/ubuntu-24.04/main/binary-amd64/Packages" + index.write_text(index.read_text().replace("Version: 1.0.0", "Version: 9.0.0")) + self.rewrite_manifest() + self.assertNotEqual(self.verify(ok=False).returncode, 0) + self.assertNotEqual(self.build(self.release2, "1.1.0", self.case_dir / "from-invalid", + "--previous", self.repo, ok=False).returncode, 0) + + def test_expired_and_future_metadata_rejected(self): + self.assertNotEqual(self.verify("--now", self.date + 31 * 86400, ok=False).returncode, 0) + self.assertNotEqual(self.verify("--now", self.date - 60, ok=False).returncode, 0) + + def test_path_manifest_classification_and_extras_rejected(self): + path = self.repo / "repository-manifest.json" + original = path.read_text() + for replacement in ("../../outside", "/absolute", "dists/../secret", "pool//double"): + manifest = json.loads(original) + manifest["files"][0]["path"] = replacement + path.write_text(json.dumps(manifest)) + self.assertNotEqual(self.verify(ok=False).returncode, 0) + path.write_text(original) + manifest = json.loads(original) + manifest["files"][0]["kind"] = "metadata" if manifest["files"][0]["kind"] == "immutable" else "immutable" + path.write_text(json.dumps(manifest)) + self.rewrite_manifest() + self.assertNotEqual(self.verify(ok=False).returncode, 0) + path.write_text(original) + (self.repo / "unadvertised").write_text("extra") + self.assertNotEqual(self.verify(ok=False).returncode, 0) + + def test_symlink_hardlink_and_same_version_repack_rejected(self): + package = next(self.repo.glob("pool/**/*.deb")) + original = package.read_bytes() + package.unlink() + package.symlink_to(self.release1 / package.name) + 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) + release = self.make_release("1.0.0", suffix="repack") + self.assertNotEqual(self.build(release, "1.0.0", self.case_dir / "repack", + "--previous", self.base, ok=False).returncode, 0) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/scripts/test-apt-workflow-preflight.py b/scripts/test-apt-workflow-preflight.py new file mode 100644 index 0000000..f3aedc4 --- /dev/null +++ b/scripts/test-apt-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-apt-workflow.sh").resolve() + + +class PreflightTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="loopwire-apt-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 > "$APT_TEST_MARKER"\nexit 1\n') + gpg.chmod(0o755) + self.marker = self.root / "used-key" + self.env = { + **os.environ, "PATH": f"{binary}:{os.environ['PATH']}", "APT_TEST_MARKER": str(self.marker), + "APT_REPOSITORY_URL": "https://packages.example.invalid/apt", + "APT_REPOSITORY_HOST": "publisher@example.invalid", "APT_REPOSITORY_ROOT": "/srv/loopwire", + "APT_SIGNING_FINGERPRINT": "A" * 40, "APT_SSH_PRIVATE_KEY": "private-ssh-fixture", + "APT_SSH_KNOWN_HOSTS": "known-hosts-fixture", "APT_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({"APT_REPOSITORY_URL": url}, "base URL") + + def test_missing_configuration(self): + self.rejected({"APT_SIGNING_KEY": ""}, "missing configuration: APT_SIGNING_KEY") + + def test_tag_is_validated(self): + self.rejected({"RELEASE_TAG": "v1.2.3; echo unexpected"}, "stable vX.Y.Z") + + def test_rollback_revision_is_validated(self): + self.rejected({"OPERATION": "rollback", "ROLLBACK_REVISION": "HEAD"}, "revision SHA-256") + + def test_fingerprint_is_validated(self): + self.rejected({"APT_SIGNING_FINGERPRINT": "short"}, "fingerprint") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/test-apt-workflow.rb b/scripts/test-apt-workflow.rb new file mode 100755 index 0000000..abb4ae3 --- /dev/null +++ b/scripts/test-apt-workflow.rb @@ -0,0 +1,47 @@ +#!/usr/bin/env ruby +require 'yaml' + +root = File.expand_path('..', __dir__) +load_workflow = ->(name) { YAML.safe_load_file(File.join(root, '.github/workflows', name)) } +check = ->(condition, message) { raise message unless condition } +apt = load_workflow.call('publish-apt.yml') +release = load_workflow.call('release.yml') +events = apt['on'] || apt[true] +check.call(!events.key?('push') && !events.key?('pull_request'), 'APT publication must not run on arbitrary pushes or PRs') +check.call(events.key?('workflow_call') && events.key?('workflow_dispatch'), 'release and operator entrypoints required') +check.call(events.fetch('schedule').any? { |schedule| schedule['cron'] == '37 5 * * 1' }, 'weekly expiry refresh required') +check.call(events.dig('workflow_dispatch', 'inputs', 'operation', 'options') == %w[publish refresh rollback], 'operator actions drifted') +check.call(apt.dig('permissions', 'contents') == 'read', 'APT publisher needs only read access to GitHub') +check.call(apt.dig('concurrency', 'cancel-in-progress') == false, 'do not interrupt an active metadata promotion') +job = apt.dig('jobs', 'publish') +check.call(job['environment'] == 'packages-production', 'production secrets must remain environment-scoped') +check.call(job['if'].include?("vars.APT_REPOSITORY_ENABLED == 'true'"), 'production must be explicitly enabled') +check.call(job['if'].include?('github.event.repository.default_branch'), 'operator publication must restrict its code ref') +check.call(job['if'] == job['if'].strip, 'job condition must not have trailing literal whitespace') +publisher = job.fetch('steps').find { |step| step['run'] == 'bash scripts/publish-apt-workflow.sh' } +check.call(publisher, 'workflow must use the reviewed publication entrypoint') +check.call(publisher.dig('env', 'OPERATION') == "${{ inputs.operation || 'refresh' }}", 'scheduled runs must refresh') +check.call(publisher.dig('env', 'RELEASE_TAG') == '${{ inputs.tag }}', 'tag input must be passed as data') +%w[APT_SIGNING_KEY APT_SSH_PRIVATE_KEY APT_SSH_KNOWN_HOSTS APT_SIGNING_PASSPHRASE].each do |name| + check.call(publisher.dig('env', name) == "${{ secrets.#{name} }}", "#{name} must come from secrets") +end +caller = release.dig('jobs', 'publish-apt') +check.call(caller['needs'] == 'publish-release', 'APT must wait for all existing release gates') +check.call(caller['uses'] == './.github/workflows/publish-apt.yml', 'release should reuse the publication workflow') +check.call(caller.dig('with', 'tag') == '${{ needs.publish-release.outputs.tag }}', 'use the verified release tag') +publish_release = release.dig('jobs', 'publish-release') +check.call(publish_release.dig('outputs', 'tag') == '${{ steps.verified-tag.outputs.tag }}', 'release output must be verified') +check.call(publish_release.fetch('steps').last['id'] == 'verified-tag', 'tag export must follow all release evidence gates') + +script = File.read(File.join(root, 'scripts/publish-apt-workflow.sh')) +publish_index = script.index('scripts/publish-package-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'), 'manual publication must validate release evidence inventory') +check.call(script.include?('fetch_status" -eq 3') && script.include?('operation" = publish'), 'only initial publish accepts empty origin') +check.call(script.index('python3 scripts/verify-apt-public.py') > publish_index, 'activation record requires verification of served bytes') +check.call(script.include?('--expected-revision "$expected"'), 'publication must use compare-and-swap') +check.call(script.include?('trap cleanup EXIT'), 'private signing/SSH files must be removed') +check.call(script.include?('unset APT_SSH_PRIVATE_KEY'), 'do not pass raw credentials to publisher child processes') +puts 'APT workflow contract passed: protected release ordering, explicit activation, expiry refresh, secret transport and public proof.' diff --git a/scripts/test-ci-workflow-paths.rb b/scripts/test-ci-workflow-paths.rb index 7b73470..c96512c 100644 --- a/scripts/test-ci-workflow-paths.rb +++ b/scripts/test-ci-workflow-paths.rb @@ -28,6 +28,7 @@ def selected_paths(path, event, parsed) cases = { 'apps/site/src/pages/index.astro' => [%w[web], %w[deploy web]], 'apps/site/package.json' => [%w[web], %w[deploy web]], + 'packaging/repositories/apt-channel.json' => [%w[web], %w[deploy web]], 'apps/docs/docs/guide/install.md' => [%w[web], %w[deploy web]], 'README.md' => [%w[web], %w[web]], 'packaging/README.md' => [%w[web], %w[web]], @@ -106,7 +107,7 @@ def selected_paths(path, event, parsed) check(parsed['deploy'].dig('jobs', 'deploy-bunny', 'environment') == 'docs-production', 'preserve deployment environment') condition = parsed['deploy'].dig('jobs', 'deploy-bunny', 'if') check(condition == condition.strip, 'deployment condition must not become an always-true string with trailing whitespace') -%w[release final-release-proof publish-aur continuous-tests].each do |name| +%w[release final-release-proof publish-aur publish-apt continuous-tests].each do |name| workflow = YAML.safe_load_file(File.join(ROOT, '.github/workflows', "#{name}.yml")) events = workflow['on'] || workflow[true] check(!events.key?('pull_request'), "#{name}: deliberate operator workflows must not gain PR triggers") diff --git a/scripts/test-publish-package-repository.py b/scripts/test-publish-package-repository.py new file mode 100644 index 0000000..df0731b --- /dev/null +++ b/scripts/test-publish-package-repository.py @@ -0,0 +1,539 @@ +#!/usr/bin/env python3 +"""Publisher regression tests; real signed fixtures require Debian/Ubuntu tools. + +Run: python3 scripts/test-publish-package-repository.py +Add --with-ssh inside a disposable root Docker container with openssh-server to +exercise the real transport. It starts sshd only in that container, uses temporary +host/client keys and known_hosts, and removes them and the server on completion. +No production credentials, services, or audio configuration are used. +""" + +import argparse +import base64 +import hashlib +import importlib.util +import io +import json +import os +from pathlib import Path +import shutil +import socket +import stat +import subprocess +import sys +import tarfile +import tempfile +import time +import unittest +from unittest import mock + + +SCRIPT = Path(__file__).with_name("publish-package-repository.py") + + +def load(name, path): + specification = importlib.util.spec_from_file_location(name, path) + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +publisher = load("publisher", SCRIPT) +FPR = "A" * 40 +WITH_SSH = False + + +def fixture(root, version="1", revision_date=1, previous=None): + root.mkdir() + files = {} + if previous: + prior = json.loads((previous / publisher.MANIFEST).read_text()) + for entry in prior["files"]: + if entry["kind"] == "immutable": + files[entry["path"]] = (previous / entry["path"]).read_bytes() + files[f"keys/{FPR}.asc"] = b"synthetic key for filesystem-only tests" + suites = [] + for suite in publisher.SUITES: + package = f"pool/{suite}/main/l/loopwire/loopwire_{version}_amd64.deb" + files[package] = b"package " + version.encode() + suites.append({"name": suite, "architecture": "amd64", "component": "main", "packages": []}) + for suffix in ("Packages", "Packages.gz"): + data = f"index {suite} {version} {suffix}".encode() + prefix = f"dists/{suite}/main/binary-amd64" + files[f"{prefix}/{suffix}"] = data + files[f"{prefix}/by-hash/SHA256/{hashlib.sha256(data).hexdigest()}"] = data + for suffix in ("Release", "InRelease"): + files[f"dists/{suite}/{suffix}"] = f"{suite} {version} {revision_date} {suffix}".encode() + entries = [] + for path, data in sorted(files.items()): + target = root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(data) + entries.append({"path": path, "kind": publisher.classify(path), "size": len(data), + "sha256": hashlib.sha256(data).hexdigest()}) + manifest = {"schemaVersion": 1, "createdAt": revision_date, "validUntil": revision_date + 2592000, + "signingFingerprint": FPR, "suites": suites, "files": entries} + write_manifest(root, manifest) + return json.loads((root / publisher.MANIFEST).read_text()) + + +def write_manifest(root, manifest): + manifest.pop("revision", None) + manifest["revision"] = hashlib.sha256(publisher.canonical(manifest)).hexdigest() + (root / publisher.MANIFEST).write_text(json.dumps(manifest)) + + +class PublicationTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory(prefix="loopwire-publisher-tests-") + self.directory = Path(self.temporary.name) + self.root = self.directory / "origin" + self.first = self.directory / "first" + self.second = self.directory / "second" + self.one = fixture(self.first) + self.two = fixture(self.second, "2", 2, self.first) + + def tearDown(self): + self.temporary.cleanup() + + def publish_first(self): + return publisher.publish_at(self.root, self.first, FPR, "empty") + + def assert_current(self, revision): + self.assertEqual(publisher.state(self.root, "current"), {"revision": revision}) + + def test_publish_idempotence_and_revision_compare_and_swap(self): + self.assertEqual(self.publish_first()["status"], "published") + self.assert_current(self.one["revision"]) + self.assertEqual(self.publish_first()["status"], "unchanged") + with self.assertRaisesRegex(publisher.PublicationError, "compare-and-swap"): + publisher.publish_at(self.root, self.second, FPR, "empty") + self.assert_current(self.one["revision"]) + publisher.publish_at(self.root, self.second, FPR, self.one["revision"]) + self.assert_current(self.two["revision"]) + self.assertTrue((self.root / "snapshots" / self.one["revision"]).is_dir()) + + def test_restrictive_umask_preserves_public_and_private_permissions(self): + previous_umask = os.umask(0o077) + try: + self.publish_first() + + def permissions(path): + return stat.S_IMODE(path.stat().st_mode) + + self.assertEqual(permissions(self.root), 0o755) + public = self.root / "public" + for path in (public, *public.rglob("*")): + self.assertEqual(permissions(path), 0o755 if path.is_dir() else 0o644, str(path)) + for private in (self.root / "snapshots", self.root / "state"): + for path in (private, *private.rglob("*")): + self.assertEqual(permissions(path), 0o700 if path.is_dir() else 0o600, str(path)) + self.assertEqual(permissions(self.root / ".publish.lock"), 0o600) + + def interrupt(label): + if label == "journal": + raise InterruptedError("inspect durable pending journal permissions") + + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at(self.root, self.second, FPR, self.one["revision"]) + self.assertEqual(permissions(self.root / "state/pending.json"), 0o600) + finally: + os.umask(previous_umask) + + def test_existing_operator_directory_permissions_are_preserved(self): + self.root.mkdir(mode=0o750) + (self.root / "public").mkdir(mode=0o750) + self.root.chmod(0o750) + (self.root / "public").chmod(0o750) + self.publish_first() + self.assertEqual(stat.S_IMODE(self.root.stat().st_mode), 0o750) + self.assertEqual(stat.S_IMODE((self.root / "public").stat().st_mode), 0o750) + + def test_old_pool_and_by_hash_survive_without_previous_in_candidate(self): + self.publish_first() + independent = self.directory / "independent" + fixture(independent, "3", 3) + publisher.publish_at(self.root, independent, FPR, self.one["revision"]) + for entry in self.one["files"]: + if entry["kind"] == "immutable": + self.assertEqual(publisher.digest(self.root / "public" / entry["path"]), entry["sha256"]) + + def test_immutable_collision_does_not_change_metadata_or_snapshot(self): + self.publish_first() + entry = next(item for item in self.two["files"] if item["path"].endswith("loopwire_1_amd64.deb")) + target = self.second / entry["path"] + target.write_bytes(b"replaced published package") + entry.update(size=target.stat().st_size, sha256=publisher.digest(target)) + write_manifest(self.second, self.two) + with self.assertRaisesRegex(publisher.PublicationError, "immutable URL collision"): + publisher.publish_at(self.root, self.second, FPR, self.one["revision"]) + self.assert_current(self.one["revision"]) + self.assertIsNone(publisher.state(self.root, "pending")) + self.assertFalse((self.root / "snapshots" / self.two["revision"]).exists()) + + def test_order_uploads_every_immutable_before_suite_commit(self): + self.publish_first() + events = [] + + def check(label): + events.append(label) + if label == "immutable": + for entry in self.two["files"]: + if entry["kind"] == "immutable": + self.assertEqual(publisher.digest(self.root / "public" / entry["path"]), entry["sha256"]) + for suite in publisher.SUITES: + path = f"dists/{suite}/InRelease" + self.assertEqual((self.root / "public" / path).read_bytes(), (self.first / path).read_bytes()) + if label == "committed:debian-13": + path = "dists/ubuntu-24.04/InRelease" + self.assertEqual((self.root / "public" / path).read_bytes(), (self.first / path).read_bytes()) + + with mock.patch.object(publisher, "_checkpoint", side_effect=check): + publisher.publish_at(self.root, self.second, FPR, self.one["revision"]) + self.assertLess(events.index("immutable"), events.index("committed:debian-13")) + self.assertLess(events.index("committed:debian-13"), events.index("committed:ubuntu-24.04")) + + def test_killed_process_leaves_recoverable_journal_and_blocks_fetch(self): + self.publish_first() + code = ( + "import importlib.util,os,sys; from pathlib import Path; " + "s=importlib.util.spec_from_file_location('publisher',sys.argv[1]); " + "p=importlib.util.module_from_spec(s); s.loader.exec_module(p); " + "p._checkpoint=lambda label: os._exit(97) if label=='committed:debian-13' else None; " + "p.publish_at(Path(sys.argv[2]),Path(sys.argv[3]),sys.argv[4],sys.argv[5])" + ) + process = subprocess.run([sys.executable, "-c", code, str(SCRIPT), str(self.root), + str(self.second), FPR, self.one["revision"]], check=False) + self.assertEqual(process.returncode, 97) + self.assert_current(self.one["revision"]) + self.assertEqual(publisher.state(self.root, "pending")["revision"], self.two["revision"]) + with self.assertRaisesRegex(publisher.PublicationError, "recover"): + with publisher.selected_snapshot(self.root, FPR): + pass + with self.assertRaisesRegex(publisher.PublicationError, "recover"): + publisher.publish_at(self.root, self.first, FPR, self.one["revision"]) + publisher.recover_at(self.root, FPR, self.two["revision"]) + self.assert_current(self.two["revision"]) + self.assertIsNone(publisher.state(self.root, "pending")) + + def test_every_promotion_checkpoint_is_resumable_with_same_candidate(self): + checkpoints = ("journal", "immutable", "metadata:debian-13", "committed:debian-13", + "metadata:ubuntu-24.04", "committed:ubuntu-24.04", "current") + for index, checkpoint in enumerate(checkpoints): + with self.subTest(checkpoint=checkpoint): + root = self.directory / f"origin-{index}" + + def interrupt(label): + if label == checkpoint: + raise InterruptedError("simulated process interruption") + + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at(root, self.first, FPR, "empty") + self.assertIsNotNone(publisher.state(root, "pending")) + publisher.publish_at(root, self.first, FPR, "empty") + self.assertEqual(publisher.state(root, "current")["revision"], self.one["revision"]) + self.assertIsNone(publisher.state(root, "pending")) + + def test_exclusive_lock_blocks_publish_and_fetch(self): + self.publish_first() + with publisher.locked(self.root, create=True): + with self.assertRaisesRegex(publisher.PublicationError, "locked"): + publisher.publish_at(self.root, self.second, FPR, self.one["revision"]) + with self.assertRaisesRegex(publisher.PublicationError, "locked"): + with publisher.selected_snapshot(self.root, FPR): + pass + + def test_empty_fetch_has_no_filesystem_side_effects(self): + with self.assertRaises(publisher.EmptyRepository): + with publisher.selected_snapshot(self.root, FPR): + pass + self.assertFalse(self.root.exists()) + + def test_fetch_selects_retained_snapshot(self): + self.publish_first() + publisher.publish_at(self.root, self.second, FPR, self.one["revision"]) + with publisher.selected_snapshot(self.root, FPR, self.one["revision"]) as (snapshot, manifest): + self.assertEqual(snapshot.name, self.one["revision"]) + self.assertEqual(manifest, self.one) + + def test_malicious_path_kind_and_revision_fail_before_root_writes(self): + cases = ("../../escape", "/etc/passwd", "dists/../escape", "state/current.json", "pool//x") + for index, bad in enumerate(cases): + with self.subTest(path=bad): + candidate = self.directory / f"bad-{index}" + manifest = fixture(candidate) + manifest["files"][0]["path"] = bad + write_manifest(candidate, manifest) + with self.assertRaises(publisher.PublicationError): + publisher.publish_at(self.root, candidate, FPR, "empty") + self.assertFalse(self.root.exists()) + self.one["files"][0]["kind"] = "immutable" + write_manifest(self.first, self.one) + with self.assertRaisesRegex(publisher.PublicationError, "kind"): + publisher.publish_at(self.root, self.first, FPR, "empty") + + def test_candidate_symlinks_and_hardlinks_are_rejected(self): + target = self.first / "unlisted" + target.symlink_to(self.second / publisher.MANIFEST) + with self.assertRaisesRegex(publisher.PublicationError, "symlink"): + self.publish_first() + target.unlink() + os.link(self.first / publisher.MANIFEST, target) + with self.assertRaisesRegex(publisher.PublicationError, "hardlink"): + self.publish_first() + self.assertFalse(self.root.exists()) + + def test_origin_symlink_and_unmanaged_content_are_rejected(self): + elsewhere = self.directory / "elsewhere" + elsewhere.mkdir() + self.root.symlink_to(elsewhere, target_is_directory=True) + with self.assertRaisesRegex(publisher.PublicationError, "symlink"): + self.publish_first() + self.assertEqual(list(elsewhere.iterdir()), []) + self.root.unlink() + (self.root / "public").mkdir(parents=True) + (self.root / "public/existing").write_text("unmanaged") + with self.assertRaisesRegex(publisher.PublicationError, "unmanaged"): + self.publish_first() + + def test_public_symlink_and_drift_rejected(self): + self.publish_first() + target = self.root / "public" / self.one["files"][0]["path"] + target.unlink() + target.symlink_to(self.first / self.one["files"][0]["path"]) + with self.assertRaisesRegex(publisher.PublicationError, "symlink"): + self.publish_first() + target.unlink() + target.write_bytes(b"drift") + with self.assertRaisesRegex(publisher.PublicationError, "drifted"): + self.publish_first() + + def test_archive_rejects_traversal_links_and_duplicate_entries(self): + for kind in ("traversal", "symlink", "hardlink", "duplicate"): + with self.subTest(kind=kind): + archive = io.BytesIO() + with tarfile.open(fileobj=archive, mode="w") as output: + member = tarfile.TarInfo("../escape" if kind == "traversal" else publisher.MANIFEST) + member.size = 2 + if kind in ("symlink", "hardlink"): + member.type = tarfile.SYMTYPE if kind == "symlink" else tarfile.LNKTYPE + member.linkname = "/etc/passwd" + output.addfile(member, io.BytesIO(b"{}")) + if kind == "duplicate": + output.addfile(member, io.BytesIO(b"{}")) + archive.seek(0) + destination = self.directory / kind + destination.mkdir() + with self.assertRaises(publisher.PublicationError): + publisher.read_archive(archive, destination) + + def test_remote_arguments_are_data_and_host_trust_is_mandatory(self): + args = argparse.Namespace(ssh="publisher@example.invalid", ssh_port=2222, known_hosts=None, identity_file=None) + request = {"root": "/tmp/path with 'quotes';$(touch /tmp/unsafe)", "action": "fetch"} + command = publisher.ssh_command(args, request) + self.assertIn("StrictHostKeyChecking=yes", command) + self.assertIn("BatchMode=yes", command) + self.assertIn("ForwardAgent=no", command) + import shlex + remote = shlex.split(command[-1]) + self.assertEqual(remote[:2], ["python3", "-c"]) + self.assertEqual(json.loads(base64.urlsafe_b64decode(remote[-1])), request) + args.ssh = "publisher@host;touch /tmp/unsafe" + with self.assertRaises(publisher.PublicationError): + publisher.ssh_command(args, request) + + +class SignedPublicationTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + # Share the generator suite's real dpkg/OpenSSL/GPG fixture builders. + cls.fixtures = load("apt_repository_test_fixtures", SCRIPT.with_name("test-apt-repository.py")).RepositoryTests + cls.fixtures.setUpClass() + cls.root = cls.fixtures.root + cls.upgraded = cls.root / "publisher-upgraded" + cls.fixtures.build(cls.fixtures.release2, "1.1.0", cls.upgraded, "--previous", cls.fixtures.base) + cls.one = json.loads((cls.fixtures.base / publisher.MANIFEST).read_text()) + cls.two = json.loads((cls.upgraded / publisher.MANIFEST).read_text()) + + @classmethod + def tearDownClass(cls): + cls.fixtures.tearDownClass() + + def setUp(self): + self.case_dir = self.root / self.id().split(".")[-1] + self.case_dir.mkdir() + self.origin = self.case_dir / "origin" + + def command(self, action, *extra, ok=True): + command = [sys.executable, str(SCRIPT), action, "--root", str(self.origin), + "--public-key", str(self.fixtures.key), "--fingerprint", self.fixtures.fingerprint, + *map(str, extra)] + result = subprocess.run(command, capture_output=True, text=True, check=False) + if ok: + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + return result + + def publish(self, *extra, **kwargs): + return self.command("publish", "--repository", self.fixtures.base, "--expected-revision", "empty", *extra, **kwargs) + + def test_signed_publish_fetch_refresh_and_retained_rollback_input(self): + self.publish() + self.command("publish", "--repository", self.upgraded, "--expected-revision", self.one["revision"]) + fetched = self.case_dir / "fetched" + result = self.command("fetch", "--output", fetched) + self.assertEqual(json.loads(result.stdout)["revision"], self.two["revision"]) + retained = self.case_dir / "retained" + result = self.command("fetch", "--revision", self.one["revision"], "--output", retained) + self.assertEqual(json.loads(result.stdout)["revision"], self.one["revision"]) + self.assertEqual((retained / publisher.MANIFEST).read_bytes(), (self.fixtures.base / publisher.MANIFEST).read_bytes()) + + def test_invalid_signature_dry_run_and_empty_fetch_do_not_touch_origin(self): + self.publish("--dry-run", "--ssh", "unused@example.invalid") + self.assertFalse(self.origin.exists()) + result = self.command("fetch", "--output", self.case_dir / "empty", ok=False) + self.assertEqual(result.returncode, 3) + self.assertEqual(json.loads(result.stdout), {"status": "empty", "revision": None}) + self.assertFalse(self.origin.exists()) + altered = self.case_dir / "altered" + shutil.copytree(self.fixtures.base, altered) + manifest = json.loads((altered / publisher.MANIFEST).read_text()) + entry = next(item for item in manifest["files"] if item["path"].endswith("/InRelease")) + (altered / entry["path"]).write_text("invalid signature") + entry.update(size=len("invalid signature"), sha256=publisher.digest(altered / entry["path"])) + write_manifest(altered, manifest) + result = self.command("publish", "--repository", altered, "--expected-revision", "empty", ok=False) + self.assertNotEqual(result.returncode, 0) + self.assertIn("signed repository verification failed", result.stderr) + self.assertFalse(self.origin.exists()) + + def test_recovery_verifies_pending_signed_snapshot(self): + self.publish() + + def interrupt(label): + if label == "committed:debian-13": + raise InterruptedError("simulated interruption") + + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at(self.origin, self.upgraded, self.fixtures.fingerprint, self.one["revision"]) + self.command("recover", "--dry-run") + self.assertIsNotNone(publisher.state(self.origin, "pending")) + self.command("recover") + self.assertIsNone(publisher.state(self.origin, "pending")) + self.assertEqual(publisher.state(self.origin, "current")["revision"], self.two["revision"]) + + def test_expired_journal_requires_explicit_recovery_then_fresh_signing(self): + # GnuPG agent sockets must fit the platform's short Unix socket path limit. + gnupg = self.root / "historical-gnupg" + gnupg.mkdir(mode=0o700) + date = int(time.time()) - 3 * 86400 + + def run(*command): + result = subprocess.run(list(map(str, command)), capture_output=True, text=True, check=False) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout + + try: + run("gpg", "--homedir", gnupg, "--batch", "--pinentry-mode", "loopback", "--passphrase", "", + "--faked-system-time", f"{date - 60}!", "--quick-generate-key", "Historical fixture", "rsa2048", "sign", "1y") + listing = run("gpg", "--homedir", gnupg, "--with-colons", "--list-keys") + fingerprint = next(line.split(":")[9] for line in listing.splitlines() if line.startswith("fpr:")) + key = self.case_dir / "historical.asc" + key.write_text(run("gpg", "--homedir", gnupg, "--armor", "--export", fingerprint)) + candidate = self.case_dir / "expired" + run(sys.executable, SCRIPT.with_name("apt-repository.py"), "build", "--release-dir", self.fixtures.release1, + "--version", "1.0.0", "--output", candidate, "--signing-key", fingerprint, + "--gnupg-home", gnupg, "--date", date, "--valid-for-days", "1", + "--release-public-key", self.fixtures.release_public) + + def interrupt(label): + if label == "journal": + raise InterruptedError("interrupted before promotion three days ago") + + with mock.patch.object(publisher, "_checkpoint", side_effect=interrupt): + with self.assertRaises(InterruptedError): + publisher.publish_at(self.origin, candidate, fingerprint, "empty") + options = ["--public-key", key, "--fingerprint", fingerprint] + rejected = self.command("recover", *options, ok=False) + self.assertNotEqual(rejected.returncode, 0) + dry_run = self.command("recover", "--allow-expired", "--dry-run", *options) + self.assertTrue(json.loads(dry_run.stdout)["requiresRefresh"]) + self.assertIsNotNone(publisher.state(self.origin, "pending")) + completed = self.command("recover", "--allow-expired", *options) + self.assertTrue(json.loads(completed.stdout)["requiresRefresh"]) + self.assertIn("APT rejects", json.loads(completed.stdout)["nextAction"]) + self.command("fetch", "--output", self.case_dir / "expired-fetched", *options) + rejected = self.command("publish", "--repository", candidate, "--expected-revision", "empty", *options, ok=False) + self.assertNotEqual(rejected.returncode, 0) + finally: + subprocess.run(["gpgconf", "--homedir", str(gnupg), "--kill", "gpg-agent"], check=False) + + def test_actual_ssh_publish_fetch_host_trust_and_no_remote_gpg(self): + if not WITH_SSH: + self.skipTest("use --with-ssh inside a disposable Docker container") + self.assertTrue(Path("/.dockerenv").exists() and os.geteuid() == 0, + "--with-ssh is restricted to root inside a disposable Docker container") + sshd = shutil.which("sshd") + self.assertIsNotNone(sshd, "openssh-server is required for --with-ssh") + identity = self.case_dir / "identity" + host_key = self.case_dir / "host-key" + for key in (identity, host_key): + subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-f", str(key)], check=True) + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + known = self.case_dir / "known_hosts" + known.write_text(f"[127.0.0.1]:{port} " + host_key.with_suffix(".pub").read_text()) + remote_bin = self.case_dir / "remote-bin" + remote_bin.mkdir() + (remote_bin / "python3").symlink_to(sys.executable) + configuration = self.case_dir / "sshd.conf" + configuration.write_text( + f"Port {port}\nListenAddress 127.0.0.1\nHostKey {host_key}\n" + f"PidFile {self.case_dir / 'sshd.pid'}\nAuthorizedKeysFile {identity}.pub\n" + "PermitRootLogin prohibit-password\nPasswordAuthentication no\nKbdInteractiveAuthentication no\n" + f"UsePAM no\nStrictModes no\nAllowUsers root\nLogLevel ERROR\nSetEnv PATH={remote_bin}\n") + Path("/run/sshd").mkdir(exist_ok=True) + with (self.case_dir / "sshd.log").open("wb") as log: + server = subprocess.Popen([sshd, "-D", "-e", "-f", str(configuration)], stdout=log, stderr=log) + try: + for _ in range(100): + self.assertIsNone(server.poll(), "temporary sshd exited") + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.1): + break + except OSError: + time.sleep(0.05) + options = ["--ssh", "root@127.0.0.1", "--ssh-port", str(port), + "--identity-file", identity, "--known-hosts", known] + connection = argparse.Namespace(ssh="root@127.0.0.1", ssh_port=port, + identity_file=identity, known_hosts=known) + probe = publisher.ssh_command(connection, {}) + probe[-1] = "python3 -c 'import shutil; assert shutil.which(\"gpg\") is None'" + checked = subprocess.run(probe, capture_output=True, text=True, check=False) + self.assertEqual(checked.returncode, 0, checked.stderr) + # The server executes only the transported publisher source; no + # generator, GPG executable, or private signing key is uploaded. + self.publish(*options) + self.assertEqual(json.loads(self.publish(*options).stdout)["status"], "unchanged") + self.command("fetch", "--output", self.case_dir / "ssh-fetched", *options) + self.command("publish", "--repository", self.upgraded, "--expected-revision", self.one["revision"], *options) + result = self.command("publish", "--repository", self.fixtures.base, + "--expected-revision", "empty", *options, ok=False) + self.assertNotEqual(result.returncode, 0) + known.write_text("") + result = self.command("fetch", "--output", self.case_dir / "untrusted", *options, ok=False) + self.assertNotEqual(result.returncode, 0) + self.assertFalse((self.case_dir / "untrusted").exists()) + finally: + server.terminate() + server.wait(timeout=10) + + +if __name__ == "__main__": + if "--with-ssh" in sys.argv: + WITH_SSH = True + sys.argv.remove("--with-ssh") + unittest.main(verbosity=2) diff --git a/scripts/verify-apt-public.py b/scripts/verify-apt-public.py new file mode 100755 index 0000000..3c97405 --- /dev/null +++ b/scripts/verify-apt-public.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Verify served repository bytes before producing a homepage activation record.""" +import argparse +from datetime import datetime, timezone +import hashlib +import json +from pathlib import Path +import re +import ssl +import subprocess +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request + + +class NoRedirects(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, new_url): + response.close() + raise ValueError("repository verification does not follow redirects; use the canonical HTTPS URL") + + +def validate_base_url(value): + base = urllib.parse.urlsplit(value) + if (base.scheme != "https" or not base.hostname or base.username or base.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 base.port is not None and not 1 <= base.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 public-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 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") + validator = Path(__file__).with_name("apt-repository.py") + subprocess.run([ + sys.executable, str(validator), "verify", "--repository", str(args.repository), + "--public-key", str(args.public_key), "--fingerprint", fingerprint, + ], check=True, stdout=subprocess.PIPE) + manifest = json.loads((args.repository / "repository-manifest.json").read_text()) + manifest_bytes = (args.repository / "repository-manifest.json").read_bytes() + 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)) + public_entries = [*manifest["files"], { + "path": "repository-manifest.json", "size": len(manifest_bytes), + "sha256": hashlib.sha256(manifest_bytes).hexdigest(), + }] + for entry in public_entries: + url = base_url + "/" + urllib.parse.quote(entry["path"], safe="/+") + request = urllib.request.Request(url, headers={"Cache-Control": "no-cache", "User-Agent": "Loopwire-APT-Proof/1"}) + digest, size = hashlib.sha256(), 0 + 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 + with response: + if response.status != 200: + raise ValueError(f"repository returned HTTP {response.status} for {entry['path']}") + 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", + "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(public_entries)})) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, KeyError, subprocess.CalledProcessError, urllib.error.URLError) as error: + print(f"verify-apt-public: {error}", file=sys.stderr) + sys.exit(1) diff --git a/scripts/verify-apt-repository-vm-proof.mjs b/scripts/verify-apt-repository-vm-proof.mjs new file mode 100644 index 0000000..ae96aa1 --- /dev/null +++ b/scripts/verify-apt-repository-vm-proof.mjs @@ -0,0 +1,252 @@ +#!/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"; + +const repositoryRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const requiredPaths = [ + "/usr/bin/loopwire", "/usr/bin/loopwire-dsp-provider", "/usr/bin/loopwire-jack-ports", + "/usr/bin/loopwire-detect-audio", "/usr/lib/loopwire/loopwire-gui", + "/usr/share/applications/loopwire.desktop", "/usr/share/icons/hicolor/scalable/apps/loopwire.svg", +]; + +function requireThat(condition, message) { + if (!condition) throw new Error(message); +} +async function bytes(directory, name) { + const file = path.join(directory, name); + const stat = await lstat(file); + requireThat(stat.isFile() && !stat.isSymbolicLink(), `evidence must be a regular file: ${name}`); + return readFile(file); +} +async function text(directory, name, nonempty = true) { + const result = (await bytes(directory, name)).toString("utf8"); + requireThat(!nonempty || result.trim(), `empty evidence: ${name}`); + return result; +} +function tsvMap(value, label) { + const map = new Map(); + for (const line of value.trimEnd().split("\n")) { + const separator = line.indexOf("\t"); + requireThat(separator > 0, `${label} must contain key/value TSV`); + const key = line.slice(0, separator); + requireThat(!map.has(key), `${label} repeats ${key}`); + map.set(key, line.slice(separator + 1)); + } + return map; +} +function 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: 16 * 1024 * 1024 }); + requireThat(!result.error && result.status === 0, + `${program} verification failed: ${result.error?.message ?? result.stderr ?? result.stdout}`); + return result.stdout; +} +function sha256(buffer) { return createHash("sha256").update(buffer).digest("hex"); } + +export function parseInstalledHashes(value) { + const result = {}; + for (const line of value.trimEnd().split("\n")) { + const match = /^([a-f0-9]{64}) {2}(\/usr\/[^\r\n]+)$/.exec(line); + requireThat(match && !match[2].split("/").includes(".."), "invalid installed-file checksum record"); + requireThat(!Object.hasOwn(result, match[2]), `duplicate installed path: ${match[2]}`); + result[match[2]] = match[1]; + } + return result; +} + +export function debPayload(packageFile) { + // dpkg-deb handles the package's xz/zstd member. Python receives a plain tar + // stream so this works on Ubuntu 24.04's Python 3.12 as well as newer hosts. + const mount = path.dirname(packageFile); + return JSON.parse(command("bash", [path.join(repositoryRoot, "scripts/with-apt-tools.sh"), + "--read-only-path", mount, "python3", "-c", ` +import hashlib, json, pathlib, subprocess, sys, tarfile +package = pathlib.Path(sys.argv[1]) +process = subprocess.Popen(['dpkg-deb', '--fsys-tarfile', package], stdout=subprocess.PIPE, stderr=subprocess.PIPE) +files = {} +with tarfile.open(fileobj=process.stdout, mode='r|') as archive: + for member in archive: + if not member.isfile(): + continue + relative = pathlib.PurePosixPath(member.name) + assert not relative.is_absolute() and '..' not in relative.parts, 'unsafe package path' + name = '/' + str(relative) + assert name.startswith('/usr/') and name not in files, 'unexpected package path' + files[name] = hashlib.sha256(archive.extractfile(member).read()).hexdigest() +stderr = process.stderr.read().decode('utf-8', errors='replace') +assert process.wait() == 0, stderr +print(json.dumps(files)) +`, packageFile])); +} + +export function verifyLifecycle(value, baselineVersion, upgradeVersion) { + equal(value.trimEnd(), [ + `install\t${baselineVersion}\tinstalled`, `reinstall\t${baselineVersion}\tinstalled`, + `upgrade\t${upgradeVersion}\tinstalled`, `rollback\t${baselineVersion}\tinstalled`, + `remove\t${baselineVersion}\tabsent`, + ].join("\n"), "lifecycle transitions"); +} + +export async function verifyInstalledStage(directory, stage, version, baseUrl, payloadHashes) { + const prefix = `${stage}/`; + equal((await text(directory, `${prefix}package-metadata.tsv`)).trim(), + `loopwire\t${version}\tamd64\tinstall ok installed`, `${stage} package metadata`); + const files = (await text(directory, `${prefix}package-files.txt`)).trim().split("\n"); + for (const installed of requiredPaths) requireThat(files.includes(installed), `${stage} missing ${installed}`); + const hashes = parseInstalledHashes(await text(directory, `${prefix}installed-files.sha256`)); + assert.deepEqual(hashes, payloadHashes, `${stage} installed bytes must equal the signed package payload`); + const policy = await text(directory, `${prefix}apt-policy.txt`); + requireThat(policy.includes(baseUrl) && policy.includes(`Installed: ${version}`), `${stage} lacks installed version/repository origin`); + for (const help of ["background-help.txt", "dsp-provider-help.txt", "jack-provider-help.txt"]) { + await text(directory, `${prefix}${help}`); + } + const detection = JSON.parse(await text(directory, `${prefix}detect-audio.json`)); + requireThat(detection && typeof detection === "object", `${stage} detection must be JSON object/array`); + const linkage = await text(directory, `${prefix}gui-ldd.txt`); + requireThat(!linkage.includes("not found") && /libgtk-3/.test(linkage) && /libwebkit2gtk/.test(linkage), `${stage} GUI linkage missing or unresolved`); + equal((await text(directory, `${prefix}gui-launch-status.txt`)).trim(), "0", `${stage} GUI launch`); + requireThat(/^\d+(?:\n\d+)*\n?$/.test(await text(directory, `${prefix}gui-window-ids.txt`)), `${stage} lacks X11 window ids`); + const names = (await text(directory, `${prefix}gui-window-names.txt`)).trim().split("\n"); + requireThat(names.every((name) => /^(Loopwire|loopwire-gui)$/.test(name)), `${stage} lacks Loopwire application window`); + const launch = await text(directory, `${prefix}gui-launch.log`, false); + requireThat(!/error while loading shared libraries|panic|protocol error|missing acquire timeline/i.test(launch), `${stage} fatal GUI log`); + await text(directory, `${prefix}xvfb.log`, false); +} + +export async function verifyEvidence({ target, evidenceDir, gitHead }) { + requireThat(["ubuntu-24.04", "debian-13"].includes(target), "supported --target is required"); + requireThat(/^[a-f0-9]{40}$/.test(gitHead ?? ""), "--git-head must be a full lowercase commit hash"); + const summary = tsvMap(await text(evidenceDir, "summary.tsv"), "summary"); + equal(summary.get("schema"), "loopwire.apt-repository-vm-proof.v1", "schema"); + equal(summary.get("target"), target, "target"); + equal(summary.get("git_head"), gitHead, "summary commit"); + equal((await text(evidenceDir, "git-head.txt")).trim(), gitHead, "evidence commit"); + equal(summary.get("payload_kind"), "cached-release-lifecycle-fixture", "payload provenance kind"); + 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("+") ? "." : "+"}aptfixture1`; + equal(summary.get("upgrade_version"), upgradedVersion, "synthetic fixture version"); + const suffix = target === "ubuntu-24.04" ? "1ubuntu24.04" : "1debian13"; + const baselineVersion = `${version}-${suffix}`; + const upgradeVersion = `${upgradedVersion}-${suffix}`; + 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:8443", "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"), target === "ubuntu-24.04" ? "ubuntu" : "debian", "guest OS"); + equal(os.get("VERSION_ID"), target === "ubuntu-24.04" ? "24.04" : "13", "guest OS version"); + requireThat(["kvm", "qemu"].includes((await text(evidenceDir, "virtualization.txt")).trim()), "not a VM proof"); + requireThat((await text(evidenceDir, "uname.txt")).includes("Linux"), "guest kernel evidence missing"); + await text(evidenceDir, "console.log"); + const targetRows = (await readFile(path.join(repositoryRoot, "packaging/vm/native-package-targets.tsv"), "utf8")) + .split("\n").map((line) => line.split("\t")).filter((row) => row[0] === target); + requireThat(targetRows.length === 1, "target missing or duplicate in image manifest"); + const row = targetRows[0]; + const image = tsvMap(await text(evidenceDir, "image.tsv"), "image"); + for (const [key, expected] of Object.entries({ schema: "loopwire.native-package-image.v1", target, + distro: row[1], url: row[4], checksum_algorithm: row[5], checksum: row[6], actual_checksum: row[6], firmware: row[8] })) { + equal(image.get(key), expected, `image ${key}`); + } + equal((await text(evidenceDir, "initial-package-status.txt")).trim(), "absent", "clean guest status"); + requireThat(/^[a-f0-9]{64} {2}loopwire-linux-x86_64.tar.gz\n?$/.test(await text(evidenceDir, "release-payload.sha256")), "missing original payload digest"); + await text(evidenceDir, "payload-release.txt"); + const source = await text(evidenceDir, "loopwire.sources"); + for (const line of [`URIs: ${baseUrl}`, `Suites: ${target}`, "Components: main", "Architectures: amd64", + `Signed-By: /etc/apt/keyrings/loopwire-${fingerprint}.asc`]) requireThat(source.split("\n").includes(line), `APT source lacks ${line}`); + requireThat(!/Trusted:|Allow-Insecure:/i.test(source), "APT proof bypasses authentication"); + equal(await text(evidenceDir, "https-key.asc"), await text(evidenceDir, "repository-key.asc"), "HTTPS 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 packageHashes = {}; + const payloadHashes = {}; + const packageNames = await readdir(path.join(evidenceDir, "packages")); + equal(packageNames.length, 2, "package evidence count"); + for (const packageVersion of [baselineVersion, upgradeVersion]) { + const name = `loopwire_${packageVersion}_amd64.deb`; + requireThat(packageNames.includes(name), `missing package ${name}`); + packageHashes[packageVersion] = sha256(await bytes(evidenceDir, `packages/${name}`)); + payloadHashes[packageVersion] = debPayload(path.join(evidenceDir, "packages", name)); + } + for (const stage of ["initial", "upgraded", "rolled-back"]) { + const directory = path.join(evidenceDir, "repositories", stage); + const result = command("bash", [path.join(repositoryRoot, "scripts/with-apt-tools.sh"), "--read-only-path", evidenceDir, + "python3", path.join(repositoryRoot, "scripts/apt-repository.py"), "verify", "--repository", directory, + "--public-key", path.join(evidenceDir, "repository-key.asc"), "--fingerprint", fingerprint, "--now", epoch]); + JSON.parse(result); + JSON.parse(await text(evidenceDir, `repositories/${stage}-verification.json`)); + const snapshot = JSON.parse(await text(directory, "repository-manifest.json")); + const served = JSON.parse(await text(evidenceDir, `repositories/${stage}-public-verification.json`)); + equal(served.status, "verified", `${stage} HTTPS verification`); + equal(served.revision, snapshot.revision, `${stage} HTTPS revision`); + equal(served.files, snapshot.files.length + 1, `${stage} HTTPS file count including manifest`); + const packages = (await text(directory, `dists/${target}/main/binary-amd64/Packages`)).trim().split(/\n\n+/).map((block) => { + return new Map(block.split("\n").filter((line) => /^[^ :]+:/.test(line)).map((line) => { + const separator = line.indexOf(":"); + return [line.slice(0, separator), line.slice(separator + 1).trim()]; + })); + }); + for (const expectedVersion of stage === "upgraded" ? [baselineVersion, upgradeVersion] : [baselineVersion]) { + const matches = packages.filter((item) => item.get("Package") === "loopwire" && item.get("Version") === expectedVersion); + requireThat(matches.length === 1, `${stage} must contain exactly one ${expectedVersion} package`); + equal(matches[0].get("SHA256"), packageHashes[expectedVersion], `${stage} signed package digest`); + equal(matches[0].get("Architecture"), "amd64", `${stage} signed architecture`); + } + if (stage !== "upgraded") requireThat(!packages.some((item) => item.get("Version") === upgradeVersion), `${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 })) { + await verifyInstalledStage(evidenceDir, stage, expectedVersion, baseUrl, payloadHashes[expectedVersion]); + const log = await text(evidenceDir, `${stage}.log`); + requireThat(log.includes("loopwire") && log.includes(expectedVersion), `${stage} lacks APT operation/version log`); + } + const commands = await text(evidenceDir, "commands.log"); + for (const needle of ["apt-get install -y loopwire=", "apt-get install --reinstall", "apt-get install --only-upgrade", + "apt-get install --allow-downgrades", "apt-get remove -y loopwire", "smoke_installed", "xdotool"]) { + requireThat(commands.includes(needle), `missing executed command: ${needle}`); + } + for (const file of ["bootstrap.log", "bootstrap-update.log", "upgrade-update.log", "rollback-update.log", "remove.log", "source-removal.log", "source-removal-update.log"]) { + await text(evidenceDir, file); + } + const requests = await text(evidenceDir, "https-server.log"); + requireThat(requests.includes(`/keys/${fingerprint}.asc`) && requests.includes(`/dists/${target}/InRelease`) && requests.includes(".deb"), "missing real HTTPS key/index/package request evidence"); + 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(!(await text(evidenceDir, "source-removal-policy.txt", false)).includes(baseUrl), "removed APT source remains active"); + return { target, gitHead, baselineVersion, upgradeVersion, fingerprint, packageHashes }; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const args = {}; + for (let index = 2; index < process.argv.length; index += 2) { + const option = process.argv[index]; + requireThat(["--target", "--evidence-dir", "--git-head"].includes(option) && process.argv[index + 1], `invalid option: ${option}`); + requireThat(!Object.hasOwn(args, option), `duplicate option: ${option}`); + args[option] = process.argv[index + 1]; + } + requireThat(args["--evidence-dir"], "--evidence-dir is required"); + const result = await verifyEvidence({ target: args["--target"], evidenceDir: path.resolve(args["--evidence-dir"]), gitHead: args["--git-head"] }); + console.log(`APT repository VM proof verified: ${result.target}`); + console.log(JSON.stringify(result)); + } catch (error) { + console.error(`verify-apt-repository-vm-proof: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/scripts/verify-apt-repository.sh b/scripts/verify-apt-repository.sh new file mode 100755 index 0000000..5a070a1 --- /dev/null +++ b/scripts/verify-apt-repository.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +if [ "${1:-}" != --inside ]; then + exec bash "$root/scripts/with-apt-tools.sh" --container bash "$root/scripts/verify-apt-repository.sh" --inside +fi +cd "$root" +export PYTHONDONTWRITEBYTECODE=1 +python3 scripts/test-apt-repository.py +python3 scripts/test-publish-package-repository.py --with-ssh +python3 scripts/test-apt-bootstrap.py +python3 scripts/test-apt-public.py +python3 scripts/test-apt-workflow-preflight.py +node scripts/test-apt-repository-vm-proof.mjs +node --test apps/site/src/lib/aptChannel.test.mjs +echo 'APT repository development verification passed.' diff --git a/scripts/verify-docs.sh b/scripts/verify-docs.sh index 76c7a5e..713dae8 100644 --- a/scripts/verify-docs.sh +++ b/scripts/verify-docs.sh @@ -6,6 +6,7 @@ root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" required_files=( "apps/docs/docs/index.md" "apps/docs/docs/guide/install.md" + "apps/docs/docs/guide/apt-repository.md" "apps/docs/docs/guide/basic-usage.md" "apps/docs/docs/guide/start-on-boot.md" "apps/docs/docs/guide/backends.md" @@ -17,6 +18,7 @@ required_files=( "apps/docs/docs/developer/screenshots.md" "apps/docs/docs/developer/vm-matrix.md" "apps/docs/docs/developer/release.md" + "apps/docs/docs/developer/apt-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" @@ -63,6 +65,12 @@ node "$root/scripts/verify-support-matrix.mjs" assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/support-matrix" assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/troubleshooting" assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/basic-usage" +assert_contains "apps/docs/docs/.vitepress/config.ts" "/guide/apt-repository" +assert_contains "apps/docs/docs/.vitepress/config.ts" "/developer/apt-repository" +assert_contains "apps/docs/docs/guide/apt-repository.md" "Signed-By" +assert_contains "apps/docs/docs/guide/apt-repository.md" "--allow-downgrades" +assert_contains "apps/docs/docs/developer/apt-repository.md" "APT_REPOSITORY_ENABLED" +assert_contains "apps/docs/docs/developer/apt-repository.md" "Final activation is a human operation" assert_contains "apps/docs/docs/.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 b37e623..2c072d5 100755 --- a/scripts/verify-github-workflows.sh +++ b/scripts/verify-github-workflows.sh @@ -95,6 +95,7 @@ fi workflows=( ".github/workflows/ci.yml" + ".github/workflows/publish-apt.yml" ".github/workflows/web.yml" ".github/workflows/aur.yml" ".github/workflows/workflow-checks.yml" @@ -335,6 +336,7 @@ assert_contains ".github/workflows/vm-matrix.yml" "apps/docs/docs/guide/support- ruby "$root/scripts/test-ci-impact.rb" ruby "$root/scripts/test-ci-workflow-paths.rb" +ruby "$root/scripts/test-apt-workflow.rb" node "$root/scripts/test-native-package-proof-snapshot.mjs" echo "GitHub workflow contract verification passed." diff --git a/scripts/verify-requirements.sh b/scripts/verify-requirements.sh index 119a853..7dce409 100644 --- a/scripts/verify-requirements.sh +++ b/scripts/verify-requirements.sh @@ -124,7 +124,8 @@ 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:requirements && pnpm verify:docs && pnpm test:setup-github && pnpm verify:scripts && pnpm verify:workflows && pnpm verify:runtime && pnpm verify:tauri && pnpm verify:apt" +assert_script "package.json" "verify:apt" "bash scripts/verify-apt-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-scripts.sh b/scripts/verify-scripts.sh index 779e62a..bc29593 100755 --- a/scripts/verify-scripts.sh +++ b/scripts/verify-scripts.sh @@ -101,6 +101,8 @@ node --check scripts/verify-vm-evidence-archive-manifest.mjs node --check scripts/release-asset-manifest.mjs node --check scripts/verify-native-package-vm-proof.mjs node --check scripts/verify-native-package-proof-snapshot.mjs +node --check scripts/verify-apt-repository-vm-proof.mjs +node --check scripts/test-apt-repository-vm-proof.mjs bash scripts/build-portable-linux-binary.sh -- --help | grep -Fq -- "--output FILE" || { echo "verify-scripts: portable builder does not accept the package-script separator" >&2 exit 1 diff --git a/scripts/with-apt-tools.sh b/scripts/with-apt-tools.sh new file mode 100755 index 0000000..3bf2595 --- /dev/null +++ b/scripts/with-apt-tools.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +image="${LOOPWIRE_APT_TOOLS_IMAGE:-loopwire-apt-tools:debian-13}" +force_container=false +mounts=() +while [ "$#" -gt 0 ]; do + case "$1" in + --container) force_container=true; shift ;; + --read-only-path) + path="$(realpath -e "${2:?missing --read-only-path value}")" + mounts+=(--volume "$path:$path:ro") + shift 2 + ;; + *) break ;; + esac +done +[ "$#" -gt 0 ] || { echo 'Usage: with-apt-tools.sh [--container] [--read-only-path PATH] COMMAND [ARG ...]' >&2; exit 2; } +available=true +for command in python3 dpkg dpkg-deb gpg gpgv openssl; 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 'APT tools or Docker are required; see the APT developer guide.' >&2; exit 1; } +if ! docker image inspect "$image" >/dev/null 2>&1; then + docker build --file "$root/packaging/repositories/Dockerfile.apt-tools" --tag "$image" "$root" >&2 +fi +# Keep absolute source/evidence paths valid for callers. Test writes belong in the disposable /tmp tree. +exec docker run --rm --network none --env PYTHONDONTWRITEBYTECODE=1 \ + --volume "$root:$root:ro" "${mounts[@]}" --workdir "$root" "$image" "$@"