diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..bd743850 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,365 @@ +name: Release + +# Tag-driven release (docs/update.md "Publishing", docs/release-keys.md): +# +# tag vX.Y.Z -> gates -> build every target in parallel -> ONE GitHub Release +# -> publish: signed metadata for channel `preview` +# +# Promotion to `stable` is a separate, manually dispatched job. It signs a new +# channel document that names the bytes `preview` already offers; it never +# builds. Nothing here reads a signing key except the jobs in the protected +# `release` environment, and they read it from a secret, by name. +on: + push: + tags: ["v*.*.*"] + workflow_dispatch: + inputs: + operation: + description: "publish-tag: run the release for an existing tag again. promote: preview -> stable. renew-targets: re-sign targets metadata before it expires." + type: choice + required: true + options: [publish-tag, promote, renew-targets] + tag: + description: "publish-tag: the tag, e.g. v1.2.3" + type: string + required: false + version: + description: "promote: the exact version preview offers, e.g. 1.2.3" + type: string + required: false + minimum_version: + description: "promote, optional: raise the signed minimum version of stable, e.g. 1.2.0. Never changes otherwise." + type: string + required: false + +permissions: + contents: read + +defaults: + run: + # `bash -eo pipefail`: a failing command in a pipeline fails the step. + shell: bash + +env: + BUN_VERSION: 1.4.2 + +jobs: + prepare: + name: Resolve the release + # Only the repository whose Pages origin is compiled into the product + # (src/update/defaults.ts) publishes; a fork builds and tests, never signs. + if: github.repository == 'Lazurio/LazurioPlatform' && (github.event_name == 'push' || inputs.operation == 'publish-tag') + runs-on: ubuntu-24.04 + timeout-minutes: 5 + outputs: + tag: ${{ steps.resolve.outputs.tag }} + version: ${{ steps.resolve.outputs.version }} + prerelease: ${{ steps.resolve.outputs.prerelease }} + previous: ${{ steps.resolve.outputs.previous }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + fetch-depth: 0 + persist-credentials: false + - id: resolve + env: + REQUESTED_TAG: ${{ inputs.tag }} + run: | + set -euo pipefail + tag="${REQUESTED_TAG:-$GITHUB_REF_NAME}" + pattern='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' + if ! [[ "$tag" =~ $pattern ]]; then + echo "::error::Not a release tag: expected vMAJOR.MINOR.PATCH[-prerelease]" + exit 1 + fi + git rev-parse --verify --quiet "refs/tags/$tag" >/dev/null || { + echo "::error::Tag does not exist" + exit 1 + } + prerelease=false + if [[ "$tag" == *-* ]]; then prerelease=true; fi + # Notes are generated against the previous tag OF THE SAME CHANNEL: a + # stable candidate is compared with the previous plain tag, a + # pre-release with whatever tag precedes it. + previous="" + while read -r candidate; do + [[ "$candidate" =~ $pattern ]] || continue + if [ "$prerelease" = false ] && [[ "$candidate" == *-* ]]; then continue; fi + if [ "$candidate" = "$tag" ]; then break; fi + previous="$candidate" + done < <(git -c versionsort.suffix=- tag --list 'v*' --sort=v:refname) + { + echo "tag=$tag" + echo "version=${tag#v}" + echo "prerelease=$prerelease" + echo "previous=$previous" + } >>"$GITHUB_OUTPUT" + + gates: + name: gates (${{ matrix.os }}) + needs: prepare + strategy: + fail-fast: true + matrix: + os: [ubuntu-24.04, macos-14] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bun install --frozen-lockfile + - run: bun run check + + build: + name: build (${{ matrix.target }}) + needs: prepare + strategy: + fail-fast: true + matrix: + include: + # Native runners only: every executable reports its own identity + # before it becomes a release asset. + - target: linux-x64 + os: ubuntu-24.04 + - target: linux-arm64 + os: ubuntu-24.04-arm + - target: darwin-arm64 + os: macos-14 + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bun install --frozen-lockfile + - name: Build the candidate with the tag as its version + env: + VERSION: ${{ needs.prepare.outputs.version }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + test -f release/root.json || { + echo "::error::release/root.json is missing: the key ceremony of docs/release-keys.md has not been performed. A release without a compiled-in trust root cannot update itself." + exit 1 + } + output="$(cd "$RUNNER_TEMP" && pwd -P)/candidate" + bun run scripts/build-candidate.ts "$output" --version "$VERSION" + reported="$("$output/lazurio" --version --json)" + test "$(jq -r .version <<<"$reported")" = "$VERSION" + test "$(jq -r .target <<<"$reported")" = "$TARGET" + test "$(jq -r .commit <<<"$reported")" = "$(git rev-parse HEAD)" + mkdir assets + cp "$output/lazurio" "assets/lazurio-$TARGET" + cp "$output/identity.json" "assets/lazurio-$TARGET.identity.json" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: lazurio-${{ matrix.target }} + path: assets/ + if-no-files-found: error + retention-days: 7 + + release: + name: Create the GitHub Release + needs: [prepare, gates, build] + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + pattern: lazurio-* + path: assets + merge-multiple: true + - name: One release with every target and generated notes + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} + PRERELEASE: ${{ needs.prepare.outputs.prerelease }} + PREVIOUS: ${{ needs.prepare.outputs.previous }} + run: | + set -euo pipefail + ls -l assets + for target in linux-x64 linux-arm64 darwin-arm64; do + test -s "assets/lazurio-$target" + test -s "assets/lazurio-$target.identity.json" + done + if gh release view "$TAG" >/dev/null 2>&1; then + # Published assets are immutable: what clients may already have + # verified is never replaced. The publish job signs what is there. + echo "::notice::Release $TAG already exists; its assets are kept as they are." + exit 0 + fi + arguments=(--verify-tag --title "Lazurio $VERSION" --generate-notes) + if [ -n "$PREVIOUS" ]; then arguments+=(--notes-start-tag "$PREVIOUS"); fi + if [ "$PRERELEASE" = true ]; then arguments+=(--prerelease); fi + gh release create "$TAG" assets/* "${arguments[@]}" + + publish: + name: Publish signed metadata to preview + needs: [prepare, release] + runs-on: ubuntu-24.04 + timeout-minutes: 45 + # Required reviewer; the only place the targets key exists. + environment: release + # No concurrency group: a job waiting for approval would hold it, and a + # newer pending run would cancel an older one. Two runs that race are + # decided by Git — the push is never forced, the loser fails, and every + # operation here is repeatable. + permissions: + contents: write + env: + TAG: ${{ needs.prepare.outputs.tag }} + VERSION: ${{ needs.prepare.outputs.version }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + ref: ${{ needs.prepare.outputs.tag }} + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + # The token and the keys exist only in the steps that name them. + - run: bun install --frozen-lockfile --ignore-scripts + - name: Check out the published tree + env: + GH_TOKEN: ${{ github.token }} + run: scripts/update-tree.sh checkout "$RUNNER_TEMP/tree" + - name: Download exactly what the release serves + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh release download "$TAG" --dir "$RUNNER_TEMP/assets" --pattern 'lazurio-*' + gh release view "$TAG" --json body --jq .body >"$RUNNER_TEMP/notes.md" + - name: Sign the release into preview + env: + LAZURIO_TUF_TARGETS_KEY: ${{ secrets.LAZURIO_TUF_TARGETS_KEY }} + LAZURIO_TUF_SNAPSHOT_KEY: ${{ secrets.LAZURIO_TUF_SNAPSHOT_KEY }} + LAZURIO_TUF_TIMESTAMP_KEY: ${{ secrets.LAZURIO_TUF_TIMESTAMP_KEY }} + run: | + bun run scripts/release-publish.ts add-release \ + --tree "$RUNNER_TEMP/tree" --root release/root.json \ + --channel preview --version "$VERSION" \ + --notes-file "$RUNNER_TEMP/notes.md" \ + --assets-dir "$RUNNER_TEMP/assets" \ + --asset-url-base "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG/" + - name: Prove every referenced object before anything is visible + run: bun run scripts/release-publish.ts verify --tree "$RUNNER_TEMP/tree" + - name: Deploy the tree in one commit + env: + GH_TOKEN: ${{ github.token }} + run: | + # The gate before every deployment: held against what is published + # (the checked-out commit) with the CLIENT's floor-vector rules. A tree + # installed Machines would refuse as metadata-rollback is never pushed. + scripts/update-tree.sh baseline "$RUNNER_TEMP/tree" "$RUNNER_TEMP/published" + bun run scripts/release-publish.ts status --rollback-only \ + --tree "$RUNNER_TEMP/tree" --against "$RUNNER_TEMP/published" + domain="$(bun run scripts/release-publish.ts pages-domain)" + UPDATE_TREE_CNAME="$domain" scripts/update-tree.sh push "$RUNNER_TEMP/tree" "Release $VERSION to preview" + - name: Wait until the published origin serves it + run: bun run scripts/release-publish.ts await-deployment --tree "$RUNNER_TEMP/tree" + - name: Validity of every role + run: bun run scripts/release-publish.ts status --tree "$RUNNER_TEMP/tree" | tee -a "$GITHUB_STEP_SUMMARY" + + promote: + name: Promote preview to stable + if: github.repository == 'Lazurio/LazurioPlatform' && github.event_name == 'workflow_dispatch' && inputs.operation == 'promote' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + environment: release + permissions: + contents: write + env: + VERSION: ${{ inputs.version }} + MINIMUM_VERSION: ${{ inputs.minimum_version }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bun install --frozen-lockfile --ignore-scripts + - env: + GH_TOKEN: ${{ github.token }} + run: scripts/update-tree.sh checkout "$RUNNER_TEMP/tree" + - name: Sign the stable channel document (same digests, no build) + env: + LAZURIO_TUF_TARGETS_KEY: ${{ secrets.LAZURIO_TUF_TARGETS_KEY }} + LAZURIO_TUF_SNAPSHOT_KEY: ${{ secrets.LAZURIO_TUF_SNAPSHOT_KEY }} + LAZURIO_TUF_TIMESTAMP_KEY: ${{ secrets.LAZURIO_TUF_TIMESTAMP_KEY }} + run: | + arguments=(--tree "$RUNNER_TEMP/tree" --root release/root.json --version "$VERSION") + # Explicit and optional: the floor never moves on its own. + if [ -n "$MINIMUM_VERSION" ]; then arguments+=(--minimum-version "$MINIMUM_VERSION"); fi + bun run scripts/release-publish.ts promote "${arguments[@]}" + - run: bun run scripts/release-publish.ts verify --tree "$RUNNER_TEMP/tree" + - env: + GH_TOKEN: ${{ github.token }} + run: | + # The gate before every deployment: held against what is published + # (the checked-out commit) with the CLIENT's floor-vector rules. A tree + # installed Machines would refuse as metadata-rollback is never pushed. + scripts/update-tree.sh baseline "$RUNNER_TEMP/tree" "$RUNNER_TEMP/published" + bun run scripts/release-publish.ts status --rollback-only \ + --tree "$RUNNER_TEMP/tree" --against "$RUNNER_TEMP/published" + domain="$(bun run scripts/release-publish.ts pages-domain)" + UPDATE_TREE_CNAME="$domain" scripts/update-tree.sh push "$RUNNER_TEMP/tree" "Promote $VERSION to stable" + - run: bun run scripts/release-publish.ts await-deployment --tree "$RUNNER_TEMP/tree" + - run: bun run scripts/release-publish.ts status --tree "$RUNNER_TEMP/tree" | tee -a "$GITHUB_STEP_SUMMARY" + + renew-targets: + name: Renew targets metadata + if: github.repository == 'Lazurio/LazurioPlatform' && github.event_name == 'workflow_dispatch' && inputs.operation == 'renew-targets' + runs-on: ubuntu-24.04 + timeout-minutes: 45 + environment: release + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: ${{ env.BUN_VERSION }} + - run: bun install --frozen-lockfile --ignore-scripts + - env: + GH_TOKEN: ${{ github.token }} + run: scripts/update-tree.sh checkout "$RUNNER_TEMP/tree" + - name: Re-sign targets, snapshot and timestamp; no target changes + env: + LAZURIO_TUF_TARGETS_KEY: ${{ secrets.LAZURIO_TUF_TARGETS_KEY }} + LAZURIO_TUF_SNAPSHOT_KEY: ${{ secrets.LAZURIO_TUF_SNAPSHOT_KEY }} + LAZURIO_TUF_TIMESTAMP_KEY: ${{ secrets.LAZURIO_TUF_TIMESTAMP_KEY }} + run: | + bun run scripts/release-publish.ts refresh --roles targets \ + --tree "$RUNNER_TEMP/tree" --root release/root.json + - run: bun run scripts/release-publish.ts verify --tree "$RUNNER_TEMP/tree" + - env: + GH_TOKEN: ${{ github.token }} + run: | + # The gate before every deployment: held against what is published + # (the checked-out commit) with the CLIENT's floor-vector rules. A tree + # installed Machines would refuse as metadata-rollback is never pushed. + scripts/update-tree.sh baseline "$RUNNER_TEMP/tree" "$RUNNER_TEMP/published" + bun run scripts/release-publish.ts status --rollback-only \ + --tree "$RUNNER_TEMP/tree" --against "$RUNNER_TEMP/published" + domain="$(bun run scripts/release-publish.ts pages-domain)" + UPDATE_TREE_CNAME="$domain" scripts/update-tree.sh push "$RUNNER_TEMP/tree" "Renew targets metadata" + - run: bun run scripts/release-publish.ts await-deployment --tree "$RUNNER_TEMP/tree" + - run: bun run scripts/release-publish.ts status --tree "$RUNNER_TEMP/tree" | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/update-metadata-refresh.yml b/.github/workflows/update-metadata-refresh.yml new file mode 100644 index 00000000..d1da942c --- /dev/null +++ b/.github/workflows/update-metadata-refresh.yml @@ -0,0 +1,94 @@ +name: Update metadata refresh + +# Keeps the published update repository fresh (docs/update.md "Publishing", +# docs/release-keys.md "Expiry calendar"). A lapsed timestamp would stop every +# Machine from updating, so snapshot and timestamp are renewed long before +# they expire: the timestamp (7 days) when fewer than 5 days remain, the +# snapshot (30 days) when fewer than 20 remain. +# +# This job holds ONLY the snapshot and timestamp keys. It cannot renew targets +# (protected `release` environment) or the root (offline). When either of +# those drops below its margin the job FAILS with a summary that says what a +# person has to do — silence here would become an outage weeks later. +on: + schedule: + - cron: "17 3 * * *" + workflow_dispatch: + +permissions: + contents: read + +defaults: + run: + # `bash -eo pipefail`: a failing command in a pipeline fails the step. + shell: bash + +jobs: + refresh: + name: Renew snapshot and timestamp, watch every role + runs-on: ubuntu-24.04 + timeout-minutes: 45 + # Only the repository whose Pages origin is compiled into the product. + if: github.repository == 'Lazurio/LazurioPlatform' + # No concurrency group (see release.yml): a race with a release job is + # decided by the never-forced push; tomorrow's run is still days early. + permissions: + contents: write + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.4.2 + - run: bun install --frozen-lockfile --ignore-scripts + - name: Check out the published tree + id: tree + env: + GH_TOKEN: ${{ github.token }} + run: | + scripts/update-tree.sh checkout "$RUNNER_TEMP/tree" + if [ -f release/root.json ] && [ -f "$RUNNER_TEMP/tree/metadata/timestamp.json" ]; then + echo "published=true" >>"$GITHUB_OUTPUT" + else + echo "::notice::Nothing is published yet (no release/root.json or no tree); nothing to refresh." + echo "published=false" >>"$GITHUB_OUTPUT" + fi + - name: Renew what is below its margin + if: steps.tree.outputs.published == 'true' + env: + LAZURIO_TUF_SNAPSHOT_KEY: ${{ secrets.LAZURIO_TUF_SNAPSHOT_KEY }} + LAZURIO_TUF_TIMESTAMP_KEY: ${{ secrets.LAZURIO_TUF_TIMESTAMP_KEY }} + run: | + bun run scripts/release-publish.ts refresh --when-low \ + --tree "$RUNNER_TEMP/tree" --root release/root.json + # Freshness first: a missing release asset must not also let the + # timestamp lapse for everybody. Re-signing changes no target. + - name: Deploy the tree in one commit + id: deploy + if: steps.tree.outputs.published == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + # The gate before every deployment: held against what is published + # (the checked-out commit) with the CLIENT's floor-vector rules. A tree + # installed Machines would refuse as metadata-rollback is never pushed. + scripts/update-tree.sh baseline "$RUNNER_TEMP/tree" "$RUNNER_TEMP/published" + bun run scripts/release-publish.ts status --rollback-only \ + --tree "$RUNNER_TEMP/tree" --against "$RUNNER_TEMP/published" + domain="$(bun run scripts/release-publish.ts pages-domain)" + UPDATE_TREE_CNAME="$domain" scripts/update-tree.sh push "$RUNNER_TEMP/tree" "Refresh update metadata" + - name: Wait until the published origin serves it + if: steps.deploy.outputs.changed == 'true' + run: bun run scripts/release-publish.ts await-deployment --tree "$RUNNER_TEMP/tree" + - name: Every selected artifact is still downloadable + if: ${{ !cancelled() && steps.tree.outputs.published == 'true' }} + run: bun run scripts/release-publish.ts verify --tree "$RUNNER_TEMP/tree" + - name: Validity of every role (fails below a margin) + if: ${{ !cancelled() && steps.tree.outputs.published == 'true' }} + run: | + { + echo "## Update metadata validity" + echo + } >>"$GITHUB_STEP_SUMMARY" + bun run scripts/release-publish.ts status --tree "$RUNNER_TEMP/tree" | tee -a "$GITHUB_STEP_SUMMARY" diff --git a/README.md b/README.md index 3edf9469..1e3846e5 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ their current supported implementation. - [Migration, upgrade and recovery](docs/migration-and-recovery.md) - [Proposed release cycle](docs/release-cycle.md) - [Product update contract](docs/update.md) +- [Release keys: ceremony, custody, rotation and expiry](docs/release-keys.md) - [Selective Launchpad adoption and Doctor direction](docs/legacy-adoption.md) - [Developer commands and conventions](docs/development.md) - [Future shared marketplace](docs/marketplace.md) diff --git a/docs/release-cycle.md b/docs/release-cycle.md index 99969754..ec8843f2 100644 --- a/docs/release-cycle.md +++ b/docs/release-cycle.md @@ -131,7 +131,10 @@ must be absent. The build installs frozen development dependencies without lifec scripts, runs the narrow publication guard, compiles the real CLI and emits `identity.json`. It refuses a dirty source or an existing output; partial failed output is retained, not cleaned automatically. The identity describes unsigned bytes, -not a TUF target, release authorization or reproducibility proof. +not a TUF target, release authorization or reproducibility proof. `--version ` +builds a release from its tag instead of the package version; when the committed +`release/root.json` exists its text is embedded as the product's trust root +(see [release keys](release-keys.md)), otherwise the product embeds none. The POSIX smoke accepts an existing executable, creates its own temporary Folder, initializes Czech instructions, changes to English, starts that executable's Launchpad, diff --git a/docs/release-keys.md b/docs/release-keys.md new file mode 100644 index 00000000..b2f8e29d --- /dev/null +++ b/docs/release-keys.md @@ -0,0 +1,241 @@ +# Release keys: ceremony, custody, rotation, expiry + +Status: **procedure for the Principal; not yet performed.** Until it is, +`release/root.json` does not exist, every build embeds **no** trust root, +`lazurio update` answers `trust-missing`, and the release workflow refuses to +build. This document contains names, commands and placeholders only. No real +key, key id, token or secret value belongs in this repository, in an issue, in +a pull request or in a workflow log. + +The update mechanism ([product update](update.md)) trusts four TUF roles. Their +separation is the security design; putting two of them in one place removes it. + +| Role | What it authorizes | Lifetime | Where the private key lives | Who can use it | +| --- | --- | --- | --- | --- | +| `root` | Which keys are valid for every role | 1 year | **Offline.** The Principal's password manager plus one offline copy (e.g. an encrypted USB medium in a safe). Never on GitHub, never in CI, never on a hosted Machine. | The Principal, by hand, about once a year | +| `targets` | Releases: artifacts, identities, channel documents, promotion | 90 days | GitHub Actions **environment** secret `LAZURIO_TUF_TARGETS_KEY` in environment `release`, which has a **required reviewer** | A workflow job only after a named person approves that run | +| `snapshot` | Consistency of one repository state | 30 days | GitHub Actions **repository** secret `LAZURIO_TUF_SNAPSHOT_KEY` | Release jobs and the daily refresh | +| `timestamp` | Freshness | 7 days | GitHub Actions **repository** secret `LAZURIO_TUF_TIMESTAMP_KEY` | Release jobs and the daily refresh | + +Every role has **threshold 1** with one key: the controlled-pilot policy of +[release cycle](release-cycle.md). The disclosed consequence: whoever obtains +the root key alone can replace every other key; whoever obtains the targets key +alone can publish a release that installed Machines accept. Before a broader +release, threshold custody (two of three root keys held by different people) +is evaluated again; the root format already supports it. + +Snapshot and timestamp keys are deliberately less protected: they cannot make a +Machine accept an artifact that the targets key did not sign. Stolen, they allow +freezing clients on an old but genuine state until their metadata expires — that +is what the short lifetimes bound. This holds for the publisher too, and only +because it verifies before it signs: the published tree is storage that anyone +with write access to the branch can change, so every run first verifies the +published timestamp, snapshot and targets under the published root and refuses +to continue otherwise. It never adopts unverified entries and signs them again +with the targets key (`tests/publish-repository.test.ts` forges exactly that). +What such a person can still do is present an older genuine state; clients that +have seen a newer one refuse it, and `gh-pages` history shows it. + +## What must exist on GitHub (names only) + +Settings of `Lazurio/LazurioPlatform`, created by an Organization Admin — no +workflow or agent creates them: + +1. **The published origin `https://releases.lazurio.io`.** It is compiled into + every executable (`src/update/defaults.ts`) and can never be changed for + an installed Lazurio, which is why it is a name the project controls and not + a GitHub host. Three parts, all done by people with the respective rights: + - **DNS** (whoever administers `lazurio.io`): a `CNAME` record + `releases.lazurio.io` → `lazurio.github.io`. Nothing else on that name. + - **Domain verification** (GitHub Organization `Lazurio` → Settings → + Pages → verified domains): verify `lazurio.io`, so that no other GitHub + account can ever claim `releases.lazurio.io` for its own Pages site if + this one is unpublished for a moment. + - **Pages** (repository settings): Source "Deploy from a branch", branch + `gh-pages`, folder `/`; custom domain `releases.lazurio.io`; "Enforce + HTTPS" once the certificate is issued. The branch is created by the first + release, and the workflows keep the `CNAME` file in it, so the custom + domain survives every deployment. The first release's "wait until the + published origin serves it" step can only succeed once DNS and the + certificate are live; run `publish-tag` again after that — it is a no-op + for the tree and only repeats the wait. +2. **Environment `release`**: required reviewers (at least the Principal); + "Deployment branches and tags" limited to branch `main` and tags `v*`; + environment secret `LAZURIO_TUF_TARGETS_KEY`. +3. **Repository secrets** `LAZURIO_TUF_SNAPSHOT_KEY` and + `LAZURIO_TUF_TIMESTAMP_KEY`. +4. **Tag protection / ruleset** for `v*`: only maintainers may create release + tags; a tag starts a release. +5. **Branch protection for `gh-pages`**: no force push, no deletion. The + workflows only ever append commits. +6. Recommended: notifications for failed workflow runs of "Update metadata + refresh" reach a person. That failure is the early warning described below. + +Each secret value is the complete text of the corresponding +`.private.pem` file, including its first and last line. + +## One-time ceremony + +On the Principal's own workstation, from a clean checkout of `main`, with the +pinned Bun. Use a directory **outside every Git repository** on an encrypted +volume; the tool refuses a directory inside a repository, never overwrites a +key, and never prints one. + +```sh +KEYS=/absolute/path/outside/any/repository/lazurio-release-keys + +for role in root targets snapshot timestamp; do + bun run scripts/release-keys.ts generate --role "$role" --out "$KEYS" +done + +bun run scripts/release-keys.ts init-root \ + --root-key "$KEYS/root.private.pem" \ + --targets "$KEYS/targets.public.json" \ + --snapshot "$KEYS/snapshot.public.json" \ + --timestamp "$KEYS/timestamp.public.json" \ + --out release/root.json +``` + +Then, in this order: + +1. Store `root.private.pem` in the password manager and on the offline medium. + Verify both copies can be read back. +2. Create the three secrets above from `targets.private.pem`, + `snapshot.private.pem` and `timestamp.private.pem` (GitHub web UI, or + `gh secret set --env release < file` / `gh secret set < file` + run by the Principal). Keep a second copy of these three in the password + manager: GitHub never shows a secret again, and a lost targets key costs a + root rotation. +3. Commit **only** `release/root.json` (public metadata: public keys, role + thresholds, an expiry, signatures) through a reviewed pull request. The four + `*.public.json` files are not needed afterwards; the four private files are + never committed — `bun run check:public` refuses `.pem` paths and private-key + text even if someone tries. +4. Delete the working directory `"$KEYS"` from the workstation once the copies + of step 1 and 2 are verified. +5. Record in the private custody record (not here) who holds which copy. + +The first release tag after the merge builds executables that embed this root, +creates the `gh-pages` tree with `metadata/1.root.json`, and publishes to +`preview`. + +## Everyday operation + +- **Release**: push tag `vX.Y.Z` (or `vX.Y.Z-rc.N`). Gates, three native + builds, one GitHub Release, then the `publish` job waits for the reviewer of + environment `release` and signs the release into `preview`. +- **Promotion**: "Release" workflow → *Run workflow* → `promote`, with the exact + version. Approve the environment. `stable` then names the same digests + `preview` offers; nothing is built. Pre-releases cannot be promoted. +- **Raising the minimum version**: the optional `minimum_version` input of the + same `promote` dispatch. It is the only way the signed minimum version of + `stable` changes; it can only rise, never above the promoted version. + Promoting the version `stable` already offers, with a higher value, raises + it without a release. +- **Freshness**: "Update metadata refresh" runs daily and needs no person. + +## Expiry calendar + +| Role | Lifetime | Renewed | By | If missed | +| --- | --- | --- | --- | --- | +| timestamp | 7 days | when fewer than 5 days remain (in practice every 2–3 days) | daily refresh job | no Machine can check or update until renewed | +| snapshot | 30 days | when fewer than 20 days remain | daily refresh job | same | +| targets | 90 days | by every release and promotion; otherwise manually when fewer than **30** days remain | "Release" workflow → `renew-targets`, approved in environment `release` | same | +| root | 365 days | manually when fewer than **60** days remain | the Principal, offline (`rotate-root` below), then a pull request | same, and new installations cannot establish trust | + +Expiry never stops an installed product from working; it stops checking and +updating. The daily job prints the remaining validity of every role into its +run summary and **fails** as soon as targets or root is below its margin — 30 +and 60 days before anything breaks. A failing "Update metadata refresh" run is +therefore a calendar reminder with a deadline, not noise. GitHub disables +scheduled workflows in a repository without activity for 60 days. Whether the +refresh job's own commits to `gh-pages` count as activity has **not** been +verified; treat GitHub's "scheduled workflow disabled" e-mail as an incident, +because once the job stops, metadata lapses within a week. + +Put three reminders in the Principal's calendar on the day of the ceremony: +root renewal at ten months, a quarterly look at the refresh job's summary, and +the renewal date of the `lazurio.io` domain registration — losing the domain +loses the update origin of every installed Lazurio. + +## Renewing or rotating the root + +Renewal (same keys, new expiry) and rotation (new keys) are the same command. +It needs the **current** root key, offline, and produces root N+1 signed by the +outgoing and the incoming root key, so every installation that trusts root N +accepts it. + +```sh +# Renew: same keys, one more year. +bun run scripts/release-keys.ts rotate-root \ + --current release/root.json --root-key "$KEYS/root.private.pem" \ + --out release/root.json + +# Rotate the targets key (generate the new one first, as in the ceremony). +bun run scripts/release-keys.ts rotate-root \ + --current release/root.json --root-key "$KEYS/root.private.pem" \ + --targets "$NEW/targets.public.json" --out release/root.json + +# Rotate the root key itself. +bun run scripts/release-keys.ts rotate-root \ + --current release/root.json --root-key "$KEYS/root.private.pem" \ + --new-root-key "$NEW/root.private.pem" --out release/root.json +``` + +Commit the new `release/root.json` through a pull request. After the merge: + +- replace the GitHub secret of every role whose key changed; +- the next publishing run installs `metadata/.root.json` and re-signs + exactly the roles whose key changed. If the targets key changed, that run + must be one with the targets key: dispatch `renew-targets`. The daily refresh + can do it alone only when just snapshot, timestamp or the root key changed. +- Rotate **one version at a time**: publish root N+1 before creating N+2. The + publisher refuses a root that skips a version, because clients walk the chain + one step at a time. + +New executables embed the new root; installed ones follow the chain from the +root they hold. Old roots stay published forever. + +## Compromise or loss + +Do not describe the incident, and never paste key material, in a public issue. + +- **Timestamp or snapshot key exposed**: generate a replacement, rotate the + root for that role (above), replace the secret, run the refresh workflow. + Exposure allowed freezing, not forged releases. +- **Targets key exposed**: treat every release signed since the exposure as + suspect. Rotate the targets key through the root immediately (that revokes + the old key for every client on its next check), replace the environment + secret, dispatch `renew-targets`, then review the `gh-pages` history — + it is append-only, so every signed state is still there — and publish a + higher version if a forged release was offered. Remove GitHub Release assets + that are not yours. +- **Targets, snapshot or timestamp key lost** (not exposed): same rotation, + without the urgency. +- **Root key exposed**: rotate the root key at once with the still-held key; + the old key stops being accepted by every client that has seen the new root. + A Machine that was offline meanwhile can still be shown a forged chain: say + so publicly and ship a release whose installer embeds the new root. +- **Root key lost**: nothing can be rotated or renewed any more. Before the + root expires, publish a final release under the old chain that embeds a + **new** root created by a new ceremony, and tell users to update before the + date. Machines that miss it need a fresh installation. This is why the root + key has two verified copies. +- In every case: revoke first, investigate second, and record the incident in + the private custody record. + +## What the tools guarantee, and what they do not + +`scripts/release-keys.ts` writes private keys with mode `0600`, refuses to +write inside a Git repository or over an existing file, refuses a private key +file that other accounts can read, and prints only key ids and paths. +`scripts/release-publish.ts` reads keys from the three named environment +variables (or a local `--keys-dir` for rehearsals), refuses a key the current +root does not authorize, and reports refusals by code, never by echoing input. +`bun run check:public` refuses `.pem` files and private-key text anywhere in the +work tree. + +They do **not** protect a workstation that is already compromised during the +ceremony, a reviewer who approves the `release` environment without looking, or +a maintainer who can push a `v*` tag of unreviewed source. Those are covered by +the repository rules above, not by code. diff --git a/docs/update.md b/docs/update.md index 2a26a3f6..876b5eb9 100644 --- a/docs/update.md +++ b/docs/update.md @@ -85,6 +85,7 @@ update/activation.json # present only during an update/previous.json # what the last confirmed activation replaced update/observed.json # derived observation for UI, CLI and observers update/launchpad-readiness.json # written by a running Launchpad, removed at clean exit +update/config.json # channel, recorded service and Folder; tolerant, member by member update/lock, update/activation.lock # flock files; content never read update/scratch-*/ # always safe to delete ``` @@ -247,14 +248,20 @@ activation. Disk is checked before download. with jitter (the check is one small signed document). Pill with version, notes and the single action appropriate to the status. The Launchpad shows when the running version differs from the active one. -- **CLI.** `lazurio update` (check, download, activate), `lazurio update --check`, - `lazurio update status [--json]`, `lazurio update rollback`, `lazurio --version`. +- **CLI.** `lazurio install` (the running executable proves and stages itself + as the first version; optionally writes the Launchpad's systemd user unit), + `lazurio update` (check, download, activate), `lazurio update --check`, + `lazurio update status [--json]`, `lazurio update rollback`, + `lazurio update channel [stable|preview]`, `lazurio launchpad --base ` + (the base a service-managed Launchpad announces readiness in), + `lazurio --version`. Other commands print a one-line notice from `observed.json`; they never touch the network for it. Every failure has a stable error code and a non-zero exit status that automation can classify. - **Identity in the binary.** Version, commit and target are embedded at build time, together with the TUF root and the default origin. A local - `update/config.json` holds only the channel. + `update/config.json` holds the channel and what `lazurio install` recorded + about this Machine: the service that owns the Launchpad and the Folder. ## Never silently stale @@ -310,10 +317,14 @@ tree published through GitHub Pages of this repository, where one deployment replaces the whole tree at once; the publisher uploads artifacts, verifies that everything the new metadata references is reachable, and only then publishes the tree, timestamp last. Old referenced objects are retained so a cached timestamp -always describes a complete repository. The client maps `artifacts//…` -to the asset URL explicitly and qualifies the redirect origins by test. A second -origin is added only when a real consumer (a private fork, an air-gapped -customer) needs it; the transport already takes the origin as configuration. +always describes a complete repository. The download location of an executable +is part of its **signed** target metadata (`custom.url`); the client downloads +from that signed URL, verifies the signed length and digest as before, and +follows redirects only to origins on a short compiled-in list that is qualified +by test. A second origin is added only when a real consumer (a private fork, an +air-gapped customer) needs it; the transport already takes the origins as +configuration. Key custody, the one-time ceremony, rotation and the expiry +calendar are in [release keys](release-keys.md). ## Deliberately narrow @@ -358,16 +369,31 @@ transport is shared). Two slices exist: **check** (embedded identity, `lazurio update --check`, `lazurio update status`) and **download → stage → activate → confirm or roll back** for the CLI (`lazurio update`, `--download-only`, `lazurio update rollback`, `lazurio self-check`, the internal -`lazurio update apply-worker`), plus **`lazurio install`**. Not built: the Launchpad poller, pill and -action, `restart-pending`, release notes, the one-line notice of other -commands, the compiled-in root and default origins (origins, channel and -bootstrap root are explicit options), a recorded channel, `lazurio uninstall` -and any repair of an existing installation, launchd, the publisher, and every piece of native -evidence listed above except the first native Linux run: the compiled journeys -run on the developer's macOS ARM64 inside `bun run check`, and the -`systemd-user` path was qualified on a real systemd 255 on Ubuntu ARM64 +`lazurio update apply-worker`), plus **`lazurio install`**. Not built: the +Launchpad poller, pill and action, `restart-pending`, showing release notes +(they are published and signed, nothing reads them yet), the one-line notice of +other commands, `lazurio uninstall` and any repair of an existing installation, +launchd, and every piece of native evidence listed above except the first +native Linux run: the compiled journeys run on the developer's macOS ARM64 +inside `bun run check`, and the `systemd-user` path was qualified on a real +systemd 255 on Ubuntu ARM64 ([evidence](evidence/update-linux-arm64-2026-09-19.md)). Linux x64, a hosted -canary and an actual reboot are still open. +canary and an actual reboot are still open. That evidence run used the ad-hoc +loopback fixture; the qualification bundle now serves a repository produced by +the publisher (`scripts/update-fixture-server.ts`), and that bundle has **not** +been run on a Linux Machine yet. + +A third slice adds the **publishing side** and the client's defaults: the +publisher core (`src/publish/`), the key tool and the publisher command line +(`scripts/release-keys.ts`, `scripts/release-publish.ts`), the release and +refresh workflows, the signed download location, the compiled-in repository, +origins and trust root (`src/update/defaults.ts`), and the recorded channel +with `lazurio update channel`. **None of it has run against GitHub**: no key +ceremony was performed, `release/root.json` does not exist, no secret, +environment, Pages site, DNS record, tag or release was created, and neither +workflow has ever executed. Until the ceremony every build embeds no root, and +`lazurio install` and `lazurio update` answer `trust-missing` — creating +nothing — without `--bootstrap-root`. Concrete choices fixed so far: @@ -436,7 +462,7 @@ Concrete choices fixed so far: never lower than what the files prove; a vector that is ahead cannot wedge, because it refuses only what is lower or different — the same repository state is a no-op and any later valid state exceeds it. *Violation:* - `metadata-rollback` (exit 47) with `role`, `rule`, `floor`, `offered`; only + `metadata-rollback` with `role`, `rule`, `floor`, `offered`; only the valid root chain (and its part of the vector) is kept; a root that contradicts a retained one keeps nothing. It outranks whatever else went wrong in that refresh, and an authentic LOWER role that the client itself @@ -609,14 +635,161 @@ Concrete choices fixed so far: `lazurio-launchpad.service`), then `daemon-reload` and `enable --now`. A unit of that name with other content is `unit-conflict` and is never touched, so the installer cannot fight a hosting engine's resident unit. -- **`update/config.json`** records what the installer decided — the - `ServiceSpec` and the Folder — so `lazurio update` and `update rollback` - need no `--service` or `--folder` on that Machine; a flag overrides it. - Tolerant state: missing or damaged means "no service, no Folder check". - This also answers where self-check's Folder comes from: there is still no - Folder discovery in this CLI, but an installation with a service has one - recorded Folder. -- **Evidence in the repository.** `tests/update-journey.test.ts` compiles real +- **`update/config.json`** is the one owned file of local update choices: + `{schemaVersion: 1, channel, service, folder}` — the channel + (`lazurio update channel `, or the `--channel` an installation + verified against), and what the installer decided: the `ServiceSpec` and the + Folder. `lazurio install`, `lazurio update` and `update rollback` therefore + need no `--channel`, `--service` or `--folder` on that Machine; a flag + overrides each. One module reads and writes it (`src/update/config.ts`); the + writer changes only the members it is given, so choosing a channel keeps the + service and the Folder, and an installation keeps a channel chosen before it + (an undone installation puts back the bytes it replaced). Tolerant state, + member by member: missing, damaged or of another schema means `stable`, no + service, no Folder check. Switching the channel never downgrades, because a + check never offers a version that is not newer than the running one. This + also answers where self-check's Folder comes from: there is still no Folder + discovery in this CLI, but an installation with a service has one recorded + Folder. +- **Repository format.** One static tree: `metadata/.root.json`, + `metadata/.targets.json`, `metadata/.snapshot.json`, + `metadata/timestamp.json`, and targets under their consistent-snapshot names + `targets//.`: `channels/.json`, + `artifacts//identity.json`, `releases//notes.md` and, only + when an executable is hosted by the repository itself, + `artifacts//lazurio`. Each role has its own version counter. The + loopback fixture builds every signed byte with the publisher's builders + (`src/publish/metadata.ts`, `channel-document.ts`), so the format the client + tests prove is the format the publisher writes; the fixture keeps only what a + publisher must never do (expired, tampered, malformed, rewound). +- **Publisher core.** Every operation reads the tree and returns a *plan*: files + to add, in order, the timestamp last. `applyPlan` decides for the whole plan + before the first byte is written that no existing immutable path would get + other bytes, then creates files exclusively and replaces only + `timestamp.json`. Nothing is deleted. The tree is storage, not authority: + before anything is planned, the published timestamp, snapshot and targets must + verify under the published root, and numbered metadata above what the + timestamp references is refused — forged entries are never adopted and signed + again with the real targets key, and an interrupted local run is cleaned up by + a person, not guessed around. Operations: `add-release` (repeatable: the same + release is a no-op; an equal or lower version, other bytes under a reached + version, the same artifact under another URL, or a dropped target are + refused; an identity the client would refuse is never signed), `promote` + (exact version required, same artifact paths, pre-releases refused), `refresh` + (targets / snapshot / timestamp, each pulling the roles below it), `status` + (margins root 60, targets 30, snapshot 20, timestamp 5 days; exit 3 below + one), `verify` (tree objects by length and digest; the signed URL of every + artifact a channel selects is downloaded through the client's own transport) + and root installation (the successor of the published root is verified as a + chain link and re-signs exactly the roles whose key changed; a root the tree + already holds is a no-op; a gap or other bytes are refused). Lifetimes + default to root 365, targets 90, snapshot 30, timestamp 7 days. A key is + needed only for a role that is actually signed and must be one the current + root names. +- **The publisher and the floor vector.** Nothing in the publisher had to + change for the vector, and a journey proves it rather than assumes it + (`tests/update-publish-floors.test.ts`): one Machine keeps its base through + release A, release B, promotion, a timestamp refresh, a snapshot refresh, a + targets renewal, a rotation of the root and targets keys, and release C, and + never sees `metadata-rollback`. Every re-signing is a NEW version — a refresh + never reuses a version with other signed content — `snapshot.meta` always + lists `targets.json` and never lowers it, a snapshot refresh leaves the + targets reference identical, and targets metadata keeps every earlier entry + (artifacts, identities, notes). The vector the Machine recorded from the + network equals, at every step, the one the publisher computes from its tree. + Shown an older generation of the same repository — every byte genuine — the + same Machine answers `metadata-rollback` and keeps its vector; so it does + for a branch that was reset and re-signed at versions it had already seen + (equivocation). *The gate:* `release-publish status --against ` + holds the tree about to be deployed against the published one with the + client's own `violationOf` (`src/publish/monotonic.ts` only turns a tree + into facts; no rule is restated) and exits 4 on any violation, before the + workflows push. "Published" is the checked-out `gh-pages` commit + (`update-tree.sh baseline`), not yet what the origin serves. +- **Signed download location.** `artifacts//lazurio` keeps its target + path; the publisher puts the GitHub Release asset URL into the signed + target's `custom.url`. A check carries it to the download; without it the + repository-served consistent-snapshot name is used (fixture, mirrors). The + URL is only a location: origin and redirect policy stay with the transport. + Default origins (`src/update/defaults.ts`): `https://github.com`, and the + asset storage it redirects to — `https://release-assets.githubusercontent.com` + (observed 2026-09-19) and its predecessor + `https://objects.githubusercontent.com`. GitHub documents that storage only as + `*.githubusercontent.com`; the transport matches exact origins. An origin + outside the list — in the signed URL or in a redirect from it — is refused at + once with the typed, non-retryable `origin-refused` (no retry, no backoff: + policy does not change by waiting), and the release workflow + downloads every new asset through the same list before it publishes, so a + host GitHub introduces later stops a release rather than installed Machines. +- **Compiled-in defaults.** Repository + `https://releases.lazurio.io/{metadata,targets}/` — a domain the project + controls, a CNAME to GitHub Pages of this repository, because the origin is + compiled into every executable for good; the static tree can move behind the + name, an installed Lazurio cannot be told another one. Further: channel + `stable`, the artifact origins above, and the text of `release/root.json` + embedded by `scripts/build-candidate.ts` through a build-time define (a root + that does not verify under its own keys fails the build). `lazurio install`, + `lazurio update` and `update --check` resolve all of it in one place + (`resolveRepository` in `src/update/cli.ts`); flags only override. The + compiled-in root is the **supplied root** of the seeding rule above — it + seeds empty trust, and beside durable trust it is ignored unless it is the + verified direct successor of the trusted root; never a conflict — + and `--bootstrap-root` replaces it. Naming another repository + (`--metadata-url` with `--target-url`) replaces the compiled-in origins + entirely; only `--artifact-origin` values are added. With no compiled-in root + and no `--bootstrap-root`, a Machine without a `trust/` directory gets + `trust-missing` before anything is created — no base, no observation. +- **Install and the publisher agree on locations.** Install finds its own + bytes by the target path `artifacts//lazurio` and its identity by + `artifacts//identity.json`, fetched through the TUF client from the + static tree. The signed `custom.url` changes neither path, and install never + follows it: it downloads signed metadata, the channel document and its own + identity, nothing else (proven against a publisher-made repository whose + executable exists only as a release asset). +- **Minimum version.** The signed `minimumVersion` of `stable` changes only + through the explicit `--minimum-version` of a promotion (workflow input + `minimum_version`): never taken from `preview`, never derived, carried over + otherwise. It can only rise and never above the promoted version; promoting + the version `stable` already offers with a higher value raises it between + releases. +- **Workflows.** `release.yml`: tag `v*.*.*` → `bun run check` on Linux and + macOS → three native builds with the tag as version (each executable is asked + for its version, target and commit) → one GitHub Release with generated notes + against the previous tag of the same channel → `publish` in environment + `release`: signs exactly the assets the release serves into `preview`, + verifies, passes the rollback gate above, pushes one commit to `gh-pages` (with the `CNAME` file of the + compiled-in domain; a tree published under another domain is refused), waits until the published origin + answers the client's own `timestamp.json` URL with the new bytes (as seen from + the runner; not a statement about every cache). `promote` and `renew-targets` are manual dispatches + in the same environment; promotion never builds. An existing release is never + replaced. `update-metadata-refresh.yml`: daily; renews timestamp and snapshot + below their margins with only those two keys, deploys that first, and then + fails — with a summary table — when targets or root is below its margin or + when an artifact a channel selects can no longer be downloaded. No job uses a + concurrency group: a job waiting for approval would hold it and a newer + pending run would cancel an older one. A race between two runs is decided by + the never-forced push; the loser fails and every operation is repeatable. All + publishing jobs run only in `Lazurio/LazurioPlatform`, the repository whose + Pages origin is compiled into the product. `scripts/update-tree.sh` refuses to + push a commit that modifies or removes a published file other than the + timestamp. +- **Evidence in the repository.** `tests/update-errors.test.ts` reads the error + table as source text and fails on a duplicate code name, a duplicate exit + status, or a retired or reserved number — two stacked branches once claimed + the same status. `tests/publish-repository.test.ts` (naming, + timestamp last, refusal to rewrite, retention, promotion, refresh, margins, + root rotation, the key tool, the append-only branch) and + `tests/update-publish-journey.test.ts`: the tree the publisher wrote is served + as static files on loopback and the real client checks and downloads release + A, sees B behind a signed URL on a second origin that redirects to a third, + is refused when that third origin is not listed, sees B on `stable` only + after promotion, resolves a complete older repository from a cached older + timestamp, and follows a rotated root and targets key; a real compiled CLI + without `release/root.json` answers `trust-missing`, one with it needs no + bootstrap root and is simply ignored on the second run, when durable trust + has moved past it; `lazurio install` proves itself against the same + repository; the client sees a raised minimum version. + `tests/update-journey.test.ts` compiles real executables A, B and C of a TEST-ONLY product entry point (faults switched by a control file; product code has no test hook) and drives `bin/lazurio` against the signed loopback repository: A→B, rollback, retry without a @@ -662,17 +835,40 @@ of this work, the rest open until reviewed: is gone: that role is authenticated and handed to the floor vector. Expired targets are deliberately never kept as a file; their floor lives in the vector. -16. `update/config.json` holds the service and the Folder, not "only the - channel" as "Surfaces" says; the channel is still a flag until the - publisher slice brings defaults. `lazurio install` and `launchpad --base` - are surfaces the contract text does not list yet. -17. The floor vector refuses MORE than the five rules state, because of how +16. The floor vector refuses MORE than the five rules state, because of how the pinned client behaves (see "The floor vector" above): a reference that names the same version with other recorded length or hashes; a `snapshot.meta` entry below a role that was verified; and, after a successful refresh, unchanged roles are compared too. `metadata-rollback` is `retryable: true` in the sense of the error table. A `floors.json` of a newer schema is rebuilt like a damaged one, not refused. +17. "The client maps `artifacts//…` to the asset URL explicitly" became + a **signed** URL in the target's `custom` metadata; "Publishing" now says + so. No mapping rule exists in the client. +18. "One deployment replaces the whole tree" is one pushed commit on + `gh-pages` with Pages deploying from that branch, followed by waiting until + the origin serves the new timestamp. Between the commit and the end of the + Pages deployment clients see the complete previous tree. +19. "The publisher uploads artifacts" is the release job; the publisher itself + never uploads. It signs what the GitHub Release already serves and proves + it by downloading it. +20. Targets metadata keeps every artifact ever released (a channel may still + select it). Pruning old entries is not built. +21. Release notes are a signed target (`releases//notes.md`, at most + 256 KiB) rather than a member of the channel document, whose exact field + set is unchanged. +22. Promotion refuses pre-release versions and requires the exact version; + a release may not drop a target its channel currently serves. Neither rule + is in the text above. +23. `renew-targets` exists although the contract names only snapshot and + timestamp renewal: targets metadata expires after 90 days without a + release and only the protected environment can re-sign it. +24. The contract has the client follow redirects "only to origins on a short + compiled-in list"; a refusal is its own error code, `origin-refused` + (not retryable), which the contract's error vocabulary did not name. +25. `trust-missing` without any supplied root is answered before the base, the + lock or the observation exist, so that one failure leaves no + `observed.json` behind — unlike every other failed check. ## Removed by this contract diff --git a/scripts/build-candidate.ts b/scripts/build-candidate.ts index 01a0c7e0..d2de6865 100644 --- a/scripts/build-candidate.ts +++ b/scripts/build-candidate.ts @@ -2,7 +2,12 @@ import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { parseArgs } from "node:util"; import { folderStateSchemas } from "../src/folder/state"; -import { identityDefines, nativeTarget } from "../src/update/identity"; +import { trustRootDefines } from "../src/update/defaults"; +import { + identityDefines, + isProductVersion, + nativeTarget, +} from "../src/update/identity"; import { artifactIdentity } from "./artifact-identity"; import { candidateTarget } from "./candidate-target"; @@ -28,17 +33,20 @@ const { values, positionals, tokens } = parseArgs({ strict: true, allowPositionals: true, tokens: true, - options: { target: { type: "string" } }, + options: { target: { type: "string" }, version: { type: "string" } }, }); const output = positionals[0]; if ( !output || positionals.length !== 1 || !isAbsolute(output) || - tokens.filter((token) => token.kind === "option").length > 1 + new Set( + tokens.flatMap((token) => (token.kind === "option" ? [token.name] : [])), + ).size !== tokens.filter((token) => token.kind === "option").length || + (values.version !== undefined && !isProductVersion(values.version)) ) throw new Error( - "Supply one absolute, absent output directory and optional Linux --target", + "Supply one absolute, absent output directory, optional Linux --target and optional --version ", ); const target = candidateTarget(values.target, process.platform, process.arch); if ( @@ -54,6 +62,15 @@ const sourceCommit = git(["rev-parse", "HEAD"]); const pkg = JSON.parse(await readFile("package.json", "utf8")); if (pkg.packageManager !== `bun@${Bun.version}`) throw new Error("Pinned Bun toolchain required"); +// A release is built from its tag: the workflow passes the tag as the version. +// Without it this is a development candidate of the package version. +const version: string = values.version ?? pkg.version; +// The trust root is a committed release input (docs/release-keys.md). Until +// the key ceremony it does not exist and the product embeds NO root. +const trustRoot = await readFile("release/root.json", "utf8").catch((error) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; +}); const lockfile = await readFile("bun.lock"); await run(["install", "--frozen-lockfile", "--ignore-scripts"]); await run(["run", "check:public"]); @@ -70,10 +87,11 @@ await run([ // The executable states its own identity (docs/update.md "Identity in the // binary"); the same three values go into identity.json below. ...identityDefines({ - version: pkg.version, + version, commit: sourceCommit, target: target.target, }), + ...trustRootDefines(trustRoot), "--outfile", binary, ]); @@ -87,7 +105,7 @@ if ( if (!lockfile.equals(await readFile("bun.lock"))) throw new Error("Lock changed during build"); const identity = artifactIdentity({ - version: pkg.version, + version, target: target.target, sourceCommit, toolchain: pkg.packageManager, @@ -128,5 +146,7 @@ await writeFile( { flag: "wx", mode: 0o600 }, ); console.log( - "Built unsigned development CLI candidate; not installed or release-qualified.", + `Built unsigned CLI candidate ${version} (${target.target}); trust root ${ + trustRoot === undefined ? "NOT embedded (no release/root.json)" : "embedded" + }. Not installed or release-qualified.`, ); diff --git a/scripts/qualify-update-linux.ts b/scripts/qualify-update-linux.ts index ff30d918..59262543 100644 --- a/scripts/qualify-update-linux.ts +++ b/scripts/qualify-update-linux.ts @@ -5,7 +5,9 @@ import { identityDefines } from "../src/update/identity"; /** Builds the bundle that `scripts/qualify-update-linux.sh` runs ON a Linux * Machine: three REAL product executables (`src/cli.ts`) that differ only in - * their embedded version, the signed loopback fixture server, and the script. + * their embedded version, the loopback repository server — whose signed tree + * is produced by the publisher (`src/publish/`: add-release, then promote, per + * release) and served as static files — and the script. * Cross-compiles from any host; copying the bundle to the Machine and running * it there is the operator's step and needs nothing from this repository. * diff --git a/scripts/release-keys.ts b/scripts/release-keys.ts new file mode 100644 index 00000000..bc4587ee --- /dev/null +++ b/scripts/release-keys.ts @@ -0,0 +1,226 @@ +import { lstat, mkdir, readFile, rename, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import type { Key } from "@tufjs/models"; +import { PublishError } from "../src/publish/errors"; +import { + generateSigner, + isRoleName, + keyFromDocument, + privateKeyPem, + publicKeyDocument, + type Signer, + signerFromPem, +} from "../src/publish/keys"; +import { parseMetadata } from "../src/publish/metadata"; +import { initialRoot, nextRoot, rootRoleKeys } from "../src/publish/root"; + +/** Key tool of the release ceremony (docs/release-keys.md). It runs on the + * Principal's own Machine, never in CI. It never prints, logs or transmits a + * private key: the only place private material goes is the `0600` file of + * `generate`. + */ +export const releaseKeysHelp = `generate --role --out + Creates an Ed25519 key: .private.pem (mode 0600, never overwritten) and + .public.json. Prints the key id and the two paths, never the key. +init-root --root-key --targets + --snapshot --timestamp + --out [--lifetime-days ] + Writes root version 1 (threshold 1 per role), signed by the root key. +rotate-root --current --root-key + [--new-root-key ] [--targets ] + [--snapshot ] [--timestamp ] + --out [--lifetime-days ] + Writes root N+1 signed by the current and the new root key. Roles without an + option keep their key, so the same command renews an expiring root. + --out may equal --current; the previous root stays in Git history and in the + published tree.`; + +type Output = Readonly<{ code: number; stdout: string; stderr: string }>; + +async function readPrivateKey(path: string): Promise { + const stat = await lstat(path); + // Custody: a private key readable by another account is already disclosed. + if (!stat.isFile() || (stat.mode & 0o077) !== 0) + throw new PublishError("key-invalid", "private key file must be 0600"); + return signerFromPem(await readFile(path, "utf8")); +} + +async function readPublicKey(path: string): Promise { + return keyFromDocument(JSON.parse(await readFile(path, "utf8"))); +} + +function insideRepository(directory: string): boolean { + let probe = directory; + // The nearest existing ancestor decides; the directory may not exist yet. + for (;;) { + const result = Bun.spawnSync( + ["git", "-C", probe, "rev-parse", "--is-inside-work-tree"], + { stdout: "pipe", stderr: "ignore" }, + ); + if (result.exitCode === 0) + return result.stdout.toString().trim() === "true"; + const parent = dirname(probe); + if (parent === probe) return false; + probe = parent; + } +} + +const lifetime = (value: string | undefined): number | undefined => { + if (value === undefined) return undefined; + if (!/^[1-9]\d{0,3}$/.test(value)) + throw new PublishError("invalid-input", "lifetime-days"); + return Number(value); +}; + +export async function runReleaseKeys( + args: readonly string[], + now: () => Date = () => new Date(), +): Promise { + try { + const { values, positionals } = parseArgs({ + args: [...args], + strict: true, + allowPositionals: true, + options: { + role: { type: "string" }, + out: { type: "string" }, + "root-key": { type: "string" }, + "new-root-key": { type: "string" }, + current: { type: "string" }, + targets: { type: "string" }, + snapshot: { type: "string" }, + timestamp: { type: "string" }, + "lifetime-days": { type: "string" }, + }, + }); + const out = values.out; + if (positionals.length !== 1 || out === undefined) + return { code: 2, stdout: "", stderr: releaseKeysHelp }; + const lifetimeDays = lifetime(values["lifetime-days"]); + const options = lifetimeDays === undefined ? {} : { lifetimeDays }; + + if (positionals[0] === "generate") { + if (!isRoleName(values.role)) + throw new PublishError("invalid-input", "role"); + if (!isAbsolute(out) || resolve(out) !== out) + throw new PublishError("invalid-input", "out must be absolute"); + if (insideRepository(out)) + throw new PublishError( + "invalid-input", + "keys are never generated inside a repository", + ); + await mkdir(out, { recursive: true, mode: 0o700 }); + const signer = generateSigner(); + const secretPath = join(out, `${values.role}.private.pem`); + const publicPath = join(out, `${values.role}.public.json`); + await writeFile(secretPath, privateKeyPem(signer), { + flag: "wx", + mode: 0o600, + }); + await writeFile( + publicPath, + `${JSON.stringify(publicKeyDocument(signer), null, 2)}\n`, + { flag: "wx", mode: 0o644 }, + ); + return { + code: 0, + stdout: [ + `role: ${values.role}`, + `key id: ${signer.key.keyID}`, + `private key (keep secret, mode 0600): ${secretPath}`, + `public key: ${publicPath}`, + ].join("\n"), + stderr: "", + }; + } + + if (positionals[0] === "init-root") { + if ( + !values["root-key"] || + !values.targets || + !values.snapshot || + !values.timestamp + ) + return { code: 2, stdout: "", stderr: releaseKeysHelp }; + const rootSigner = await readPrivateKey(values["root-key"]); + const bytes = initialRoot({ + keys: { + root: [rootSigner.key], + targets: [await readPublicKey(values.targets)], + snapshot: [await readPublicKey(values.snapshot)], + timestamp: [await readPublicKey(values.timestamp)], + }, + rootSigner, + now: now(), + ...options, + }); + await mkdir(dirname(resolve(out)), { recursive: true }); + await writeFile(out, bytes, { flag: "wx", mode: 0o644 }); + return { code: 0, stdout: describe(bytes, out), stderr: "" }; + } + + if (positionals[0] === "rotate-root") { + if (!values.current || !values["root-key"]) + return { code: 2, stdout: "", stderr: releaseKeysHelp }; + const current = await readFile(values.current); + const outgoing = await readPrivateKey(values["root-key"]); + const incoming = + values["new-root-key"] === undefined + ? outgoing + : await readPrivateKey(values["new-root-key"]); + const kept = rootRoleKeys(current); + const replaced = async ( + path: string | undefined, + keys: readonly Key[], + ) => (path === undefined ? keys : [await readPublicKey(path)]); + const bytes = nextRoot({ + current, + keys: { + root: [incoming.key], + targets: await replaced(values.targets, kept.targets), + snapshot: await replaced(values.snapshot, kept.snapshot), + timestamp: await replaced(values.timestamp, kept.timestamp), + }, + rootSigners: [outgoing, incoming], + now: now(), + ...options, + }); + if (resolve(out) === resolve(values.current)) { + const temporary = `${out}.next`; + await writeFile(temporary, bytes, { flag: "wx", mode: 0o644 }); + await rename(temporary, out); + } else await writeFile(out, bytes, { flag: "wx", mode: 0o644 }); + return { code: 0, stdout: describe(bytes, out), stderr: "" }; + } + return { code: 2, stdout: "", stderr: releaseKeysHelp }; + } catch (error) { + // Never the raw error: a parser may quote its input. + return { + code: 1, + stdout: "", + stderr: + error instanceof PublishError + ? `Refused: ${error.message}` + : `Refused: ${(error as NodeJS.ErrnoException | undefined)?.code ?? "invalid arguments or input"}`, + }; + } +} + +function describe(rootBytes: Buffer, path: string): string { + const root = parseMetadata("root", rootBytes).signed; + return [ + `root version ${root.version}, expires ${root.expires}: ${path}`, + ...(["root", "targets", "snapshot", "timestamp"] as const).map( + (role) => + `${role}: ${(root.roles[role]?.keyIDs ?? []).join(", ")} (threshold ${root.roles[role]?.threshold})`, + ), + ].join("\n"); +} + +if (import.meta.main) { + const output = await runReleaseKeys(process.argv.slice(2)); + if (output.stdout) console.log(output.stdout); + if (output.stderr) console.error(output.stderr); + process.exitCode = output.code; +} diff --git a/scripts/release-publish.ts b/scripts/release-publish.ts new file mode 100644 index 00000000..58d3df16 --- /dev/null +++ b/scripts/release-publish.ts @@ -0,0 +1,488 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; +import { parseArgs } from "node:util"; +import { DistributionTransport } from "../src/distribution/transport"; +import { PublishError } from "../src/publish/errors"; +import { + isRoleName, + type RoleName, + type Signer, + signerFromPem, +} from "../src/publish/keys"; +import { timestampPath } from "../src/publish/metadata"; +import { rollbackViolations } from "../src/publish/monotonic"; +import { + addRelease, + loadRepository, + type PublishOptions, + promote, + type RefreshRole, + type ReleaseArtifact, + referencedObjects, + refresh, + repositoryStatus, +} from "../src/publish/repository"; +import { applyPlan, directoryTree, hashFile } from "../src/publish/tree"; +import { isUpdateChannel } from "../src/update/channel"; +import { + defaultArtifactOrigins, + defaultMetadataBaseUrl, + defaultRepositoryOrigin, +} from "../src/update/defaults"; +import { updateTargets } from "../src/update/identity"; + +/** Command line of the publisher core (`src/publish/`), run by the release and + * refresh workflows and by a person with a local tree. It works on a DIRECTORY + * — the checked-out `gh-pages` tree; committing, pushing and deploying that + * directory is the workflow's job. The only network use is `verify`, which + * downloads the signed artifact locations exactly as a client would. + * + * Private keys are read from the environment variables below (GitHub Actions + * secrets, by name) or from `--keys-dir` (files written by + * `scripts/release-keys.ts generate`). A key is needed only for a role that + * the operation actually signs. + */ +export const keyEnvironment: Readonly< + Record, string> +> = Object.freeze({ + targets: "LAZURIO_TUF_TARGETS_KEY", + snapshot: "LAZURIO_TUF_SNAPSHOT_KEY", + timestamp: "LAZURIO_TUF_TIMESTAMP_KEY", +}); + +export const releasePublishHelp = `Common: --tree [--root ] [--keys-dir ] + [--lifetime-days role=days,...] [--margin-days role=days,...] [--json] + Keys: ${Object.values(keyEnvironment).join(", ")} (PKCS#8 PEM) or --keys-dir. +add-release --channel --version --notes-file + --assets-dir and lazurio-.identity.json> + [--targets ] [--asset-url-base />] + [--minimum-version ] + Signs the artifacts, identities, notes and the channel document. With + --asset-url-base the executables stay release assets and their URL is signed; + without it they are published inside the tree. Every supported target must be + present unless --targets names the ones this release has. Repeating it is a no-op. +promote --version [--minimum-version ] + New signed stable document naming the artifacts preview offers for exactly + this version: the same digests, never a rebuild. --minimum-version is the ONLY + way the signed minimum version of stable changes; it can only rise, never above + the promoted version. Repeating a promotion with a higher one raises it alone. +refresh [--roles ] [--when-low] + Re-signs with a fresh expiry; no target changes. --when-low renews snapshot + and timestamp only when below their margin. Without roles it only installs + --root and re-signs what that root requires. +status [--against ] [--rollback-only] + Remaining validity of every role. Exit 3 when a role is below its margin. + With --against, the tree is also held against the published one with the + CLIENT's floor-vector rules: a lower version, the same version with other + signed content, a lowered snapshot reference or snapshot.meta entry, a root + that contradicts or drops a published one. Any of them is exit 4, whatever the + validity says: installed Machines would refuse this tree as metadata-rollback. + --rollback-only reports and judges only that (the gate before a deployment). +verify [--all-urls] [--artifact-origin ]... [--loopback-fixture] + Proves every object the current metadata references: files inside the tree by + length and digest, and the signed download location of every artifact a + channel selects (--all-urls: of every artifact) over the network. +pages-domain + Prints the host of the compiled-in repository origin: the content of the CNAME + file GitHub Pages needs in the tree. Needs no --tree. +await-deployment [--metadata-url ] [--timeout-seconds ] + Waits until the published origin (default: the one compiled into the product) + answers the client's own timestamp.json URL with the tree's bytes. It proves + the origin as seen from where it runs, not every cache in the world.`; + +type Output = Readonly<{ code: number; stdout: string; stderr: string }>; +export const exitValidityLow = 3; +export const exitRollback = 4; + +function roleDays(value: string | undefined, option: string) { + const days: Partial> = {}; + for (const part of value?.split(",") ?? []) { + const match = /^([a-z]+)=([1-9]\d{0,3})$/.exec(part); + if (!match || !isRoleName(match[1])) + throw new PublishError("invalid-input", option); + days[match[1]] = Number(match[2]); + } + return days; +} + +async function loadSigners( + keysDirectory: string | undefined, + env: Readonly>, +): Promise>> { + const signers: Partial> = {}; + for (const role of ["targets", "snapshot", "timestamp"] as const) { + let pem = env[keyEnvironment[role]]; + if (!pem && keysDirectory !== undefined) { + const path = join(keysDirectory, `${role}.private.pem`); + const stat = await lstat(path).catch(() => undefined); + if (stat) { + if (!stat.isFile() || (stat.mode & 0o077) !== 0) + throw new PublishError("key-invalid", `${role} key file mode`); + pem = await readFile(path, "utf8"); + } + } + if (pem) signers[role] = signerFromPem(pem); + } + return signers; +} + +/** The build's `identity.json` wraps the identity in its "unsigned candidate" + * statement (`scripts/build-candidate.ts`); what gets signed is the identity. + */ +async function releaseArtifacts( + directory: string, + urlBase: string | undefined, + targets: readonly string[], +): Promise { + if ( + (urlBase !== undefined && !urlBase.endsWith("/")) || + targets.length === 0 || + targets.some( + (target) => !(updateTargets as readonly string[]).includes(target), + ) + ) + throw new PublishError("invalid-input", "targets or asset-url-base"); + const artifacts: ReleaseArtifact[] = []; + for (const target of targets) { + const file = join(directory, `lazurio-${target}`); + // A target that silently went missing would strand its Machines. + if (!(await lstat(file).catch(() => undefined))?.isFile()) + throw new PublishError("invalid-input", `missing lazurio-${target}`); + const built = JSON.parse( + await readFile(`${file}.identity.json`, "utf8"), + ) as { identity?: unknown } | null; + if (!built?.identity || typeof built.identity !== "object") + throw new PublishError("invalid-input", `identity ${target}`); + artifacts.push({ + target, + ...(await hashFile(file)), + identity: Buffer.from(JSON.stringify(built.identity)), + ...(urlBase === undefined + ? { file } + : { url: `${urlBase}lazurio-${target}` }), + }); + } + return artifacts; +} + +async function verifyUrl( + transport: DistributionTransport, + object: Readonly<{ url: string; length: number; sha256: string }>, +): Promise { + const response = await transport.openRange( + object.url, + 0, + new AbortController().signal, + ); + const hash = createHash("sha256"); + let length = 0; + try { + for await (const chunk of response.body) { + length += chunk.byteLength; + if (length > object.length) break; + hash.update(chunk); + } + } finally { + response.close(); + } + if (length !== object.length || hash.digest("hex") !== object.sha256) + throw new PublishError("unreachable", object.url); +} + +export async function runReleasePublish( + args: readonly string[], + environment: Readonly<{ + env?: Readonly>; + now?: () => Date; + }> = {}, +): Promise { + let json = false; + try { + const { values, positionals } = parseArgs({ + args: [...args], + strict: true, + allowPositionals: true, + options: { + tree: { type: "string" }, + root: { type: "string" }, + "keys-dir": { type: "string" }, + "lifetime-days": { type: "string" }, + "margin-days": { type: "string" }, + json: { type: "boolean" }, + channel: { type: "string" }, + version: { type: "string" }, + "notes-file": { type: "string" }, + "assets-dir": { type: "string" }, + "asset-url-base": { type: "string" }, + "minimum-version": { type: "string" }, + targets: { type: "string" }, + roles: { type: "string" }, + "when-low": { type: "boolean" }, + "all-urls": { type: "boolean" }, + "artifact-origin": { type: "string", multiple: true }, + "loopback-fixture": { type: "boolean" }, + against: { type: "string" }, + "rollback-only": { type: "boolean" }, + "metadata-url": { type: "string" }, + "timeout-seconds": { type: "string" }, + }, + }); + json = values.json === true; + const command = positionals[0]; + if (command === "pages-domain" && positionals.length === 1) + return { + code: 0, + stdout: new URL(defaultRepositoryOrigin).host, + stderr: "", + }; + const directory = values.tree; + if ( + positionals.length !== 1 || + !directory || + !isAbsolute(directory) || + resolve(directory) !== directory + ) + return { code: 2, stdout: "", stderr: releasePublishHelp }; + const tree = directoryTree(directory); + const now = (environment.now ?? (() => new Date()))(); + const margins = roleDays(values["margin-days"], "margin-days"); + const loopback = values["loopback-fixture"] === true; + const options = async (): Promise => ({ + signers: await loadSigners( + values["keys-dir"], + environment.env ?? process.env, + ), + now, + lifetimes: roleDays(values["lifetime-days"], "lifetime-days"), + ...(values.root === undefined + ? {} + : { root: await readFile(values.root) }), + }); + const done = (result: object, text: string): Output => ({ + code: 0, + stdout: json ? JSON.stringify(result) : text, + stderr: "", + }); + + if (command === "add-release") { + if ( + !isUpdateChannel(values.channel) || + !values.version || + !values["notes-file"] || + !values["assets-dir"] + ) + return { code: 2, stdout: "", stderr: releasePublishHelp }; + const plan = await addRelease( + tree, + { + channel: values.channel, + version: values.version, + notes: await readFile(values["notes-file"], "utf8"), + artifacts: await releaseArtifacts( + values["assets-dir"], + values["asset-url-base"], + values.targets?.split(",") ?? updateTargets, + ), + ...(values["minimum-version"] === undefined + ? {} + : { minimumVersion: values["minimum-version"] }), + allowLoopbackUrls: loopback, + }, + await options(), + ); + const written = await applyPlan(directory, plan); + return done( + { ...plan.result, written }, + `${plan.result.kind}: ${plan.result.channel} ${plan.result.version} (sequence ${plan.result.sequence}); ${written.length} files written`, + ); + } + if (command === "promote") { + if (!values.version) + return { code: 2, stdout: "", stderr: releasePublishHelp }; + const plan = await promote( + tree, + { + version: values.version, + ...(values["minimum-version"] === undefined + ? {} + : { minimumVersion: values["minimum-version"] }), + }, + await options(), + ); + const written = await applyPlan(directory, plan); + return done( + { ...plan.result, written }, + `${plan.result.kind}: stable ${plan.result.version} (sequence ${plan.result.sequence}); ${written.length} files written`, + ); + } + if (command === "refresh") { + let roles = ( + values.roles?.split(",") ?? + (values["when-low"] === true ? ["snapshot", "timestamp"] : []) + ).map((role) => { + if (!["targets", "snapshot", "timestamp"].includes(role)) + throw new PublishError("invalid-input", "roles"); + return role as RefreshRole; + }); + if (values["when-low"] === true) { + const repository = await loadRepository(tree); + const low = repository?.timestamp + ? repositoryStatus(repository, now, margins).low + : []; + roles = roles.filter((role) => low.includes(role)); + } + const plan = await refresh(tree, roles, await options()); + const written = await applyPlan(directory, plan); + return done( + { written }, + written.length + ? `re-signed: ${written.join(", ")}` + : "nothing to renew", + ); + } + if (command === "status") { + const repository = await loadRepository(tree); + if (!repository) throw new PublishError("repository-invalid", "empty"); + if ( + values.against !== undefined && + (!isAbsolute(values.against) || + resolve(values.against) !== values.against) + ) + throw new PublishError("invalid-input", "against"); + if (values["rollback-only"] === true && values.against === undefined) + throw new PublishError("invalid-input", "rollback-only needs against"); + const rollback = + values.against === undefined + ? [] + : await rollbackViolations(directoryTree(values.against), tree); + const status = repositoryStatus(repository, now, margins); + const validityOnly = values["rollback-only"] !== true; + const lines = [ + ...(validityOnly + ? [ + "| role | version | expires | days left | margin | state |", + "| --- | --- | --- | --- | --- | --- |", + ...status.roles.map( + (role) => + `| ${role.role} | ${role.version} | ${role.expires} | ${role.remainingDays} | ${role.marginDays} | ${role.low ? "LOW" : "ok"} |`, + ), + ...(status.low.length + ? [ + "", + `Below margin: ${status.low.join(", ")}. Snapshot and timestamp are renewed by the scheduled refresh; targets needs the protected \`release\` environment; root needs the offline root key (docs/release-keys.md).`, + ] + : []), + ] + : []), + ...(values.against === undefined + ? [] + : rollback.length + ? [ + "", + "ROLLBACK: installed Machines would refuse this tree as `metadata-rollback`:", + ...rollback.map( + (violation) => + `- ${violation.role}: rule ${violation.rule}, published ${violation.floor}, offered ${violation.offered}`, + ), + ] + : ["", "Monotonic against the published tree: ok"]), + ]; + return { + code: rollback.length + ? exitRollback + : validityOnly && status.low.length + ? exitValidityLow + : 0, + stdout: json + ? JSON.stringify({ ...(validityOnly ? status : {}), rollback }) + : lines.join("\n").trim(), + stderr: "", + }; + } + if (command === "verify") { + const referenced = await referencedObjects(tree); + const origins = [ + ...new Set([ + ...(loopback ? [] : defaultArtifactOrigins), + ...(values["artifact-origin"] ?? []), + ]), + ]; + if (origins.length === 0) + throw new PublishError("invalid-input", "artifact-origin"); + // The client's own transport: same origin list, same redirect policy. + const transport = new DistributionTransport( + origins, + 30 * 60_000, + new AbortController().signal, + loopback, + ); + const urls = referenced.external.filter( + (object) => + values["all-urls"] === true || + referenced.selected.includes(object.path), + ); + for (const object of urls) { + try { + await verifyUrl(transport, object); + } catch (error) { + if (error instanceof PublishError) throw error; + throw new PublishError("unreachable", object.url); + } + } + return done( + { verifiedUrls: urls.map((object) => object.url) }, + `verified: the tree and ${urls.length} download locations`, + ); + } + if (command === "await-deployment") { + const url = new URL( + "timestamp.json", + values["metadata-url"] ?? defaultMetadataBaseUrl, + ); + const seconds = values["timeout-seconds"] ?? "1200"; + if (!/^[1-9]\d{0,4}$/.test(seconds)) + throw new PublishError("invalid-input", "timeout-seconds"); + const expected = await tree.read(timestampPath); + if (!expected) throw new PublishError("repository-invalid", "empty"); + const transport = new DistributionTransport( + [url.origin], + Number(seconds) * 1000 + 60_000, + new AbortController().signal, + loopback, + ); + const deadline = performance.now() + Number(seconds) * 1000; + for (;;) { + // The exact URL a client asks for, caches included: a cache that + // still answers with the previous timestamp is what clients see. + const served = await transport + .downloadBytes(url.href, 1024 * 1024) + .catch(() => undefined); + if (served?.equals(expected)) + return done({ deployed: true }, `deployed: ${url.href}`); + if (performance.now() >= deadline) + throw new PublishError("unreachable", url.href); + await Bun.sleep(loopback ? 50 : 10_000); + } + } + return { code: 2, stdout: "", stderr: releasePublishHelp }; + } catch (error) { + const code = error instanceof PublishError ? error.code : "failed"; + const detail = + error instanceof PublishError + ? error.detail + : ((error as NodeJS.ErrnoException | undefined)?.code ?? ""); + return { + code: 1, + stdout: json ? JSON.stringify({ kind: "error", code, detail }) : "", + stderr: `Refused: ${code}${detail ? ` (${detail})` : ""}`, + }; + } +} + +if (import.meta.main) { + const output = await runReleasePublish(process.argv.slice(2)); + if (output.stdout) console.log(output.stdout); + if (output.stderr) console.error(output.stderr); + process.exitCode = output.code; +} diff --git a/scripts/update-fixture-server.ts b/scripts/update-fixture-server.ts index 2fd5ec60..eb80db72 100644 --- a/scripts/update-fixture-server.ts +++ b/scripts/update-fixture-server.ts @@ -1,18 +1,30 @@ -import { readFile, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { mkdir, writeFile } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; import { parseArgs } from "node:util"; -import { createUpdateFixture } from "./update-fixture"; +import { folderStateSchemas } from "../src/folder/state"; +import { generateSigner } from "../src/publish/keys"; +import { addRelease, promote } from "../src/publish/repository"; +import { initialRoot } from "../src/publish/root"; +import { applyPlan, directoryTree, hashFile } from "../src/publish/tree"; -/** The signed loopback fixture repository as a long-running process, for - * native qualification on a Machine where no test runner exists - * (`scripts/qualify-update-linux.sh`). Keys are generated at start and never - * leave the process; everything is served on 127.0.0.1. Not a publisher. +/** A signed loopback repository as a long-running process, for native + * qualification on a Machine where no test runner exists + * (`scripts/qualify-update-linux.sh`). + * + * The repository is produced by the PUBLISHER (`src/publish/`), exactly as the + * release workflow produces the real one: each release is `add-release` into + * `preview` followed by `promote` to `stable`, written into a directory tree + * that is then served as static files — what GitHub Pages does. Executables + * are hosted inside the tree (no release-asset origin exists on loopback). + * Keys are generated at start, live only in this process and sign nothing + * real; everything is served on 127.0.0.1. * * serve --state --target --commit <40 hex> * --artifact = [--artifact …] * writes /root.json and /fixture.env, then serves until killed * release --control --version --sequence - * publishes a new generation whose stable channel names + * publishes to preview and promotes it to stable; is + * the stable sequence the caller expects and is checked, not chosen */ const { values, positionals } = parseArgs({ args: process.argv.slice(2), @@ -40,60 +52,139 @@ if (positionals[0] === "release") { process.exit(response.ok ? 0 : 1); } +const state = values.state ?? ""; +const target = values.target ?? ""; +const commit = values.commit ?? ""; if ( positionals[0] !== "serve" || - !values.state || - !values.target || - !/^[0-9a-f]{40}$/.test(values.commit ?? "") || + !isAbsolute(state) || + resolve(state) !== state || + !target || + !/^[0-9a-f]{40}$/.test(commit) || !values.artifact?.length ) throw new Error("serve needs --state, --target, --commit and --artifact"); -const fixture = createUpdateFixture({ executionTarget: values.target }); -const digests = new Map(); +const files = new Map(); for (const entry of values.artifact) { const [version, file] = entry.split("=", 2); if (!version || !file) throw new Error("--artifact ="); - const artifact = fixture.addArtifact({ - bytes: await readFile(file), - version, - identity: { sourceCommit: values.commit, toolchain: `bun@${Bun.version}` }, - }); - digests.set(version, artifact.sha256); + files.set(version, file); +} + +const signers = { + root: generateSigner(), + targets: generateSigner(), + snapshot: generateSigner(), + timestamp: generateSigner(), +}; +const root = initialRoot({ + keys: { + root: [signers.root.key], + targets: [signers.targets.key], + snapshot: [signers.snapshot.key], + timestamp: [signers.timestamp.key], + }, + rootSigner: signers.root, + now: new Date(), +}); +const treeDirectory = join(state, "tree"); +await mkdir(treeDirectory, { recursive: true, mode: 0o700 }); +const tree = directoryTree(treeDirectory); + +/** What the release workflow does for one tag, then what `promote` does. */ +async function release(version: string): Promise { + const file = files.get(version); + if (!file) throw new Error("unknown version"); + const { sha256, length } = await hashFile(file); + const options = { signers, now: new Date(), root }; + await applyPlan( + treeDirectory, + await addRelease( + tree, + { + channel: "preview", + version, + notes: `Qualification release ${version}.`, + artifacts: [ + { + target, + sha256, + length, + file, + identity: Buffer.from( + JSON.stringify({ + schemaVersion: 1, + version, + target, + sourceCommit: commit, + toolchain: `bun@${Bun.version}`, + schemas: folderStateSchemas, + artifactSha256: sha256, + artifactBytes: length, + }), + ), + }, + ], + }, + options, + ), + ); + const promoted = await promote(tree, { version }, options); + await applyPlan(treeDirectory, promoted); + return promoted.result.sequence; } -fixture.publish(); + +const repository = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const path = decodeURIComponent(new URL(request.url).pathname); + if (path.split("/").includes("..")) + return new Response(null, { status: 400 }); + const file = Bun.file(join(treeDirectory, path)); + return (await file.exists()) + ? new Response(file) + : new Response("not found", { status: 404 }); + }, +}); const control = Bun.serve({ hostname: "127.0.0.1", port: 0, - fetch(request) { + async fetch(request) { const url = new URL(request.url); const version = url.searchParams.get("version") ?? ""; - const sequence = Number(url.searchParams.get("sequence")); - const artifactSha256 = digests.get(version); + const expected = Number(url.searchParams.get("sequence")); if ( request.method !== "POST" || url.pathname !== "/release" || - !artifactSha256 || - !Number.isSafeInteger(sequence) || - sequence < 1 + !files.has(version) ) return new Response("refused", { status: 400 }); - fixture.release("stable", { sequence, version, artifactSha256 }); - return new Response( - `released ${version} as stable sequence ${sequence} (generation ${fixture.version()})`, - ); + try { + const sequence = await release(version); + if (sequence !== expected) + return new Response( + `released ${version}, but as stable sequence ${sequence}, not ${expected}`, + { status: 409 }, + ); + return new Response(`released ${version} as stable sequence ${sequence}`); + } catch (error) { + return new Response( + `refused: ${error instanceof Error ? error.message : "failed"}`, + { status: 400 }, + ); + } }, }); -await writeFile(join(values.state, "root.json"), fixture.bootstrapRoot, { - mode: 0o600, -}); +await writeFile(join(state, "root.json"), root, { mode: 0o600 }); await writeFile( - join(values.state, "fixture.env"), + join(state, "fixture.env"), [ - `METADATA_URL=${fixture.metadataBaseUrl}`, - `TARGET_URL=${fixture.targetBaseUrl}`, + `METADATA_URL=${repository.url}metadata/`, + `TARGET_URL=${repository.url}targets/`, `CONTROL_URL=${control.url.href}`, "", ].join("\n"), diff --git a/scripts/update-fixture.ts b/scripts/update-fixture.ts index 3113a156..28618648 100644 --- a/scripts/update-fixture.ts +++ b/scripts/update-fixture.ts @@ -1,29 +1,32 @@ +import { channelDocumentBytes } from "../src/publish/channel-document"; import { - createHash, - generateKeyPairSync, - type KeyObject, - sign, -} from "node:crypto"; + generateSigner, + type RoleName, + type Signer, +} from "../src/publish/keys"; import { - Key, - Metadata, - MetaFile, - Root, - Signature, - Snapshot, - TargetFile, - Targets, - Timestamp, -} from "@tufjs/models"; + buildRoot, + buildSnapshot, + buildTargets, + buildTimestamp, + consistentTargetPath, + metadataPath, + sha256, + type TargetEntry, +} from "../src/publish/metadata"; /** Local synthetic signed repository for the update core: consistent * snapshots, one ephemeral key PER role, numbered immutable roots, snapshots * and targets, hash-addressed channel documents, and old objects retained — * the layout docs/update.md "Publishing" prescribes. Keys never leave this - * process and the bytes never leave the loopback server. Not a publisher. + * process and the bytes never leave the loopback server. + * + * Every signed byte is built by the publisher's own builders + * (`src/publish/metadata.ts`, `channel-document.ts`), so the repository format + * the client tests prove is the format the publisher writes. What stays here + * is what a publisher must never do: expired or tampered metadata, malformed + * documents, rewinding, and a misbehaving server. */ -type RoleName = "root" | "targets" | "snapshot" | "timestamp"; -type Signer = { key: Key; secret: KeyObject }; export type FixtureChannel = Readonly<{ sequence: number; version: string; @@ -45,26 +48,6 @@ export type FixtureFault = /** Answer a range request with the whole object and `200`. */ | Readonly<{ kind: "ignore-range" }>; -function signer(id: string): Signer { - const pair = generateKeyPairSync("ed25519"); - return { - secret: pair.privateKey, - key: new Key({ - keyID: id, - keyType: "ed25519", - scheme: "ed25519", - keyVal: { - public: pair.publicKey - .export({ type: "spki", format: "pem" }) - .toString(), - }, - }), - }; -} - -const sha256 = (bytes: Buffer) => - createHash("sha256").update(bytes).digest("hex"); - export function createUpdateFixture(input: { executionTarget: string; artifact?: Buffer; @@ -79,35 +62,19 @@ export function createUpdateFixture(input: { const rangeRequests: { path: string; range: string | null }[] = []; const future = () => new Date(Date.now() + 3600_000).toISOString(); const signers: Record = { - root: signer("root-1"), - targets: signer("targets-1"), - snapshot: signer("snapshot-1"), - timestamp: signer("timestamp-1"), + root: generateSigner(), + targets: generateSigner(), + snapshot: generateSigner(), + timestamp: generateSigner(), }; const served = new Map(); const blocked = new Map(); const requests: string[] = []; - const channels = new Map(); + const channels = new Map<"stable" | "preview", FixtureChannel>(); const timestamps = new Map(); let rootVersion = 0; let version = 0; - const signedBy = ( - value: Root | Targets | Snapshot | Timestamp, - by: readonly Signer[], - ) => { - const metadata = new Metadata(value); - for (const { key, secret } of by) - metadata.sign( - (bytes) => - new Signature({ - keyID: key.keyID, - sig: sign(null, bytes, secret).toString("hex"), - }), - ); - return Buffer.from(JSON.stringify(metadata.toJSON())); - }; - /** Publish root N+1. A rotation replaces the root key and is signed by both * the outgoing and the incoming key, as the client requires. */ @@ -116,22 +83,21 @@ export function createUpdateFixture(input: { expires?: string; }): Buffer => { const outgoing = signers.root; - for (const role of options.rotate ?? []) - signers[role] = signer(`${role}-${rootVersion + 2}`); + for (const role of options.rotate ?? []) signers[role] = generateSigner(); rootVersion += 1; - const root = new Root({ + const bytes = buildRoot({ version: rootVersion, - specVersion: "1.0.0", expires: options.expires ?? future(), - consistentSnapshot: true, + keys: { + root: [signers.root.key], + targets: [signers.targets.key], + snapshot: [signers.snapshot.key], + timestamp: [signers.timestamp.key], + }, + signers: + outgoing === signers.root ? [outgoing] : [outgoing, signers.root], }); - for (const role of ["root", "targets", "snapshot", "timestamp"] as const) - root.addKey(signers[role].key, role); - const bytes = signedBy( - root, - outgoing === signers.root ? [outgoing] : [outgoing, signers.root], - ); - served.set(`/metadata/${rootVersion}.root.json`, bytes); + served.set(`/${metadataPath("root", rootVersion)}`, bytes); return bytes; }; @@ -152,93 +118,63 @@ export function createUpdateFixture(input: { } = {}, ) => { version = options.at ?? version + 1; - const files: Record = {}; - for (const [path, bytes] of artifactTargets) { - files[path] = new TargetFile({ - path, - length: bytes.length, - hashes: { sha256: sha256(bytes) }, - }); - // Consistent-snapshot name: `/.`. - const slash = path.lastIndexOf("/"); - served.set( - `/targets/${path.slice(0, slash + 1)}${sha256(bytes)}.${path.slice(slash + 1)}`, - bytes, - ); - } + const entries = new Map(); + const put = (path: string, bytes: Buffer) => { + entries.set(path, { length: bytes.length, sha256: sha256(bytes) }); + served.set(`/${consistentTargetPath(path, sha256(bytes))}`, bytes); + }; + for (const [path, bytes] of artifactTargets) put(path, bytes); for (const [name, channel] of channels) { - const document = Buffer.from( - channel.rawDocument ?? - JSON.stringify({ - schemaVersion: 1, - channel: name, - sequence: channel.sequence, - version: channel.version, - minimumVersion: channel.minimumVersion ?? "0.0.1", - targets: channel.withoutTarget - ? {} - : { - [input.executionTarget]: channel.artifactSha256 - ? `artifacts/${channel.artifactSha256}/lazurio` - : artifactPath, - }, - }), - ); - const path = `channels/${name}.json`; - files[path] = new TargetFile({ - path, - length: document.length, - hashes: { sha256: sha256(document) }, - }); - served.set( - `/targets/channels/${sha256(document)}.${name}.json`, - document, + const targets: Record = channel.withoutTarget + ? {} + : { + [input.executionTarget]: channel.artifactSha256 + ? `artifacts/${channel.artifactSha256}/lazurio` + : artifactPath, + }; + put( + `channels/${name}.json`, + channel.rawDocument !== undefined + ? Buffer.from(channel.rawDocument) + : channelDocumentBytes({ + channel: name, + sequence: channel.sequence, + version: channel.version, + minimumVersion: channel.minimumVersion ?? "0.0.1", + targets, + }), ); } - const fields = (role: "targets" | "snapshot" | "timestamp") => ({ + const expires = (role: "targets" | "snapshot" | "timestamp") => + options.expires?.[role] ?? future(); + const targets = buildTargets({ version, - specVersion: "1.0.0", - expires: options.expires?.[role] ?? future(), + expires: expires("targets"), + targets: entries, + signer: signers.targets, + }); + const snapshot = buildSnapshot({ + version, + expires: expires("snapshot"), + targetsVersion: version, + targetsBytes: targets, + ...(options.extraMeta ? { extraMeta: options.extraMeta } : {}), + signer: signers.snapshot, }); - const targets = signedBy( - new Targets({ ...fields("targets"), targets: files }), - [signers.targets], - ); - const snapshot = signedBy( - new Snapshot({ - ...fields("snapshot"), - meta: { - "targets.json": new MetaFile({ - version, - length: targets.length, - hashes: { sha256: sha256(targets) }, - }), - ...Object.fromEntries( - Object.entries(options.extraMeta ?? {}).map(([name, entry]) => [ - name, - new MetaFile({ version: entry }), - ]), - ), - }, - }), - [signers.snapshot], - ); const referenced = options.timestampReferences === undefined ? snapshot - : served.get(`/metadata/${options.timestampReferences}.snapshot.json`); + : served.get( + `/${metadataPath("snapshot", options.timestampReferences)}`, + ); if (!referenced) throw new Error("Unknown fixture generation"); - const timestamp = signedBy( - new Timestamp({ - ...fields("timestamp"), - snapshotMeta: new MetaFile({ - version: options.timestampReferences ?? version, - length: referenced.length, - hashes: { sha256: sha256(referenced) }, - }), - }), - [signers.timestamp], - ); + const timestamp = buildTimestamp({ + version, + expires: expires("timestamp"), + snapshotVersion: options.timestampReferences ?? version, + snapshotBytes: referenced, + signer: signers.timestamp, + }); // Same length, different bytes: the signed hash no longer matches. const damaged = (bytes: Buffer) => { const copy = Buffer.from(bytes); @@ -246,14 +182,14 @@ export function createUpdateFixture(input: { return copy; }; served.set( - `/metadata/${version}.targets.json`, + `/${metadataPath("targets", version)}`, options.tamper === "targets" ? damaged(targets) : targets, ); served.set( - `/metadata/${version}.snapshot.json`, + `/${metadataPath("snapshot", version)}`, options.tamper === "snapshot" ? damaged(snapshot) : snapshot, ); - served.set("/metadata/timestamp.json", timestamp); + served.set(`/${metadataPath("timestamp", version)}`, timestamp); timestamps.set(version, timestamp); }; diff --git a/scripts/update-tree.sh b/scripts/update-tree.sh new file mode 100755 index 00000000..237a5701 --- /dev/null +++ b/scripts/update-tree.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Git plumbing of the published update repository for the release and refresh +# workflows. The tree (TUF metadata, channel documents, identities, notes) +# lives on the `gh-pages` branch, which GitHub Pages serves; one pushed commit +# is one deployment of the whole tree. History is only ever appended: no force +# push, nothing deleted. The token is sent as a request header and never +# becomes part of a remote URL, a config file or the log. +# +# update-tree.sh checkout clone the branch, or start it +# update-tree.sh baseline +# what is PUBLISHED: `metadata/` of the +# checked-out commit, untouched by this +# run, for `release-publish status --against` +# update-tree.sh push +# +# UPDATE_TREE_CNAME, when set, is the custom domain of the Pages site: `push` +# adds the CNAME file GitHub Pages reads it from, and refuses another value. +# +# Needs GITHUB_REPOSITORY and GH_TOKEN (contents: write for `push`). A local +# rehearsal names another remote — a bare repository — in UPDATE_TREE_REMOTE +# and needs neither. +set -euo pipefail + +command="${1:?checkout, baseline or push}" +tree="${2:?tree directory}" +if [ -n "${UPDATE_TREE_REMOTE:-}" ]; then + remote="$UPDATE_TREE_REMOTE" + authenticated() { git "$@"; } +else + remote="https://github.com/${GITHUB_REPOSITORY:-}.git" + authenticated() { + : "${GITHUB_REPOSITORY:?}" "${GH_TOKEN:?}" + local encoded + encoded="$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 | tr -d '\n')" + # GitHub masks the token itself, not its encoded form. + if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::add-mask::$encoded"; fi + git -c "http.https://github.com/.extraheader=AUTHORIZATION: basic $encoded" "$@" + } +fi + +case "$command" in + checkout) + mkdir -p "$tree" + git -C "$tree" init --quiet --initial-branch gh-pages + git -C "$tree" remote add origin "$remote" + # Exit status 2 is the ONLY answer that means "no such branch". A failed + # login, an outage or anything else must stop the run: read as "nothing + # published yet", it would let the daily refresh go green while the + # timestamp runs out. + status=0 + authenticated -C "$tree" ls-remote --exit-code --heads origin gh-pages >/dev/null || status=$? + if [ "$status" -eq 0 ]; then + authenticated -C "$tree" fetch --quiet --depth 1 origin gh-pages + git -C "$tree" reset --quiet --hard FETCH_HEAD + elif [ "$status" -eq 2 ]; then + echo "::notice::No gh-pages branch yet; this run starts the published tree." + else + echo "::error::Cannot read the published tree (git ls-remote exit $status)." + exit 1 + fi + ;; + baseline) + # Needs no remote and no token: it reads the local commit. + destination="${3:?baseline directory}" + mkdir "$destination" + if git -C "$tree" rev-parse --verify --quiet HEAD >/dev/null && + [ -n "$(git -C "$tree" ls-tree HEAD metadata)" ]; then + git -C "$tree" archive HEAD metadata | tar -x -C "$destination" + fi + ;; + push) + message="${3:?commit message}" + # Served as plain files: no Jekyll build, no ignored names. + touch "$tree/.nojekyll" + if [ -n "${UPDATE_TREE_CNAME:-}" ]; then + if [ ! -e "$tree/CNAME" ]; then + printf '%s\n' "$UPDATE_TREE_CNAME" >"$tree/CNAME" + elif [ "$(tr -d '[:space:]' <"$tree/CNAME")" != "$UPDATE_TREE_CNAME" ]; then + echo "::error::The tree is published under another domain than the product is built for." + exit 1 + fi + fi + git -C "$tree" add --all + if git -C "$tree" diff --cached --quiet; then + echo "Nothing changed; nothing to deploy." + echo "changed=false" >>"${GITHUB_OUTPUT:-/dev/null}" + exit 0 + fi + # Append-only: a published file is never modified or removed, except the + # timestamp, which exists to be replaced. + rewritten="$(git -C "$tree" diff --cached --name-only --diff-filter=MDRT -- . ':(exclude)metadata/timestamp.json')" + if [ -n "$rewritten" ]; then + echo "::error::Refusing to rewrite or remove published files:" + echo "$rewritten" + exit 1 + fi + git -C "$tree" \ + -c user.name="github-actions[bot]" \ + -c user.email="41898282+github-actions[bot]@users.noreply.github.com" \ + commit --quiet --message "$message" + authenticated -C "$tree" push --quiet origin HEAD:refs/heads/gh-pages + echo "changed=true" >>"${GITHUB_OUTPUT:-/dev/null}" + ;; + *) + echo "Unknown command: $command" >&2 + exit 2 + ;; +esac diff --git a/src/distribution/transport.ts b/src/distribution/transport.ts index fc2fdf50..224a081a 100644 --- a/src/distribution/transport.ts +++ b/src/distribution/transport.ts @@ -14,6 +14,16 @@ export class TransferTooLargeError extends Error { } } +/** The URL, or a redirect it answered with, names an origin outside this + * transport's list. Its own class because no retry can change the answer. + * `origin` carries scheme, host and port only — never a path or a query. + */ +export class OriginRefusedError extends Error { + constructor(readonly origin: string) { + super("Distribution origin refused"); + } +} + export type RangeResponse = Readonly<{ /** Offset of the first body byte: the requested one for `206`, `0` when the * server ignored the range and sent the whole object. */ @@ -82,7 +92,7 @@ export class DistributionTransport implements Fetcher { let url = this.parse(value); for (let redirects = 0; ; redirects++) { if (!this.origins.has(url.origin)) - throw new Error("Distribution origin refused"); + throw new OriginRefusedError(url.origin); if (signal.aborted) throw new Error("Distribution transfer cancelled"); let response: Response; try { diff --git a/src/publish/channel-document.ts b/src/publish/channel-document.ts new file mode 100644 index 00000000..ea61c619 --- /dev/null +++ b/src/publish/channel-document.ts @@ -0,0 +1,53 @@ +import { + type ChannelDocument, + parseChannelDocument, + type UpdateChannel, +} from "../update/channel"; +import { PublishError } from "./errors"; + +/** Bytes of `channels/.json`. The format has ONE owner, the client's + * parser (`src/update/channel.ts`): whatever is built here is parsed by it + * before it is returned, so the publisher can never sign a document that an + * installed Lazurio refuses. + */ +export function channelDocumentBytes(input: { + channel: UpdateChannel; + sequence: number; + version: string; + minimumVersion: string; + /** Execution target → `artifacts//lazurio`. */ + targets: Readonly>; +}): Buffer { + const targets: Record = {}; + for (const target of Object.keys(input.targets).sort()) + targets[target] = input.targets[target] as string; + const bytes = Buffer.from( + JSON.stringify({ + schemaVersion: 1, + channel: input.channel, + sequence: input.sequence, + version: input.version, + minimumVersion: input.minimumVersion, + targets, + }), + ); + try { + parseChannelDocument(bytes, input.channel); + } catch { + throw new PublishError("invalid-input", "channel-document"); + } + return bytes; +} + +export function sameRelease( + document: ChannelDocument, + version: string, + targets: Readonly>, +): boolean { + const names = Object.keys(targets).sort(); + return ( + document.version === version && + Object.keys(document.targets).sort().join() === names.join() && + names.every((name) => document.targets[name] === targets[name]) + ); +} diff --git a/src/publish/errors.ts b/src/publish/errors.ts new file mode 100644 index 00000000..84c97b20 --- /dev/null +++ b/src/publish/errors.ts @@ -0,0 +1,40 @@ +/** Refusals of the publisher. A code is stable for the workflows that read + * it; `detail` names a path, role or version and never key material. + */ +export const publishErrorCodes = [ + /** A private or public key could not be read or is not Ed25519. */ + "key-invalid", + /** The operation must sign a role whose key was not supplied. */ + "key-missing", + /** The supplied key is not authorized for the role by the current root. */ + "key-unauthorized", + /** The tree does not hold a verifiable repository. */ + "repository-invalid", + /** An empty tree needs the initial root. */ + "root-missing", + /** The offered root does not continue the published chain. */ + "root-chain", + /** Bytes for an immutable path differ from the published ones. */ + "immutable-conflict", + "invalid-input", + /** The channel already offers this or a newer version. */ + "not-newer", + /** The release drops a target the channel currently serves. */ + "target-dropped", + /** Promotion asked for a version the source channel does not offer. */ + "version-mismatch", + /** A role's remaining validity is below its margin. */ + "validity-low", + /** A referenced object is missing, or has another length or digest. */ + "unreachable", +] as const; +export type PublishErrorCode = (typeof publishErrorCodes)[number]; + +export class PublishError extends Error { + constructor( + readonly code: PublishErrorCode, + readonly detail = "", + ) { + super(detail ? `${code}: ${detail}` : code); + } +} diff --git a/src/publish/keys.ts b/src/publish/keys.ts new file mode 100644 index 00000000..c92c0c23 --- /dev/null +++ b/src/publish/keys.ts @@ -0,0 +1,125 @@ +import { + createHash, + createPrivateKey, + createPublicKey, + generateKeyPairSync, + type KeyObject, + sign, +} from "node:crypto"; +import { Key, Signature } from "@tufjs/models"; +import { PublishError } from "./errors"; + +/** The four top-level TUF roles; the repository uses no delegations. */ +export const roleNames = ["root", "targets", "snapshot", "timestamp"] as const; +export type RoleName = (typeof roleNames)[number]; + +export function isRoleName(value: unknown): value is RoleName { + return (roleNames as readonly unknown[]).includes(value); +} + +/** A private key in memory together with the public TUF key it answers for. + * Nothing in this module logs, prints or serializes `secret`; the only export + * of private material is `privateKeyPem`, called by the key tool when it + * writes a new key file. + */ +export type Signer = Readonly<{ key: Key; secret: KeyObject }>; + +/** Public half as it appears in root metadata and in `.public.json`. */ +export type PublicKeyDocument = Readonly<{ + schemaVersion: 1; + keyid: string; + keytype: "ed25519"; + scheme: "ed25519"; + keyval: Readonly<{ public: string }>; +}>; + +/** Raw 32-byte Ed25519 public key as hex, the TUF specification's `keyval` + * form. The SubjectPublicKeyInfo of Ed25519 is a fixed 12-byte prefix followed + * by the key. + */ +function publicHex(secretOrPublic: KeyObject): string { + const spki = createPublicKey(secretOrPublic).export({ + type: "spki", + format: "der", + }); + if (spki.length !== 44) throw new PublishError("key-invalid"); + return spki.subarray(12).toString("hex"); +} + +/** Deterministic and unique per key: SHA-256 of the raw public key. The pinned + * client never recomputes a key id; it only matches ids inside one root. + */ +const keyIdOf = (hex: string) => + createHash("sha256").update(Buffer.from(hex, "hex")).digest("hex"); + +function tufKey(hex: string, keyId = keyIdOf(hex)): Key { + return new Key({ + keyID: keyId, + keyType: "ed25519", + scheme: "ed25519", + keyVal: { public: hex }, + }); +} + +function signerOf(secret: KeyObject, keyId?: string): Signer { + if (secret.type !== "private" || secret.asymmetricKeyType !== "ed25519") + throw new PublishError("key-invalid"); + return Object.freeze({ secret, key: tufKey(publicHex(secret), keyId) }); +} + +/** A fresh Ed25519 key. `keyId` overrides the derived id (fixtures only). */ +export function generateSigner(keyId?: string): Signer { + return signerOf(generateKeyPairSync("ed25519").privateKey, keyId); +} + +/** Load a PKCS#8 PEM private key. A refusal never echoes the input. */ +export function signerFromPem(pem: string): Signer { + let secret: KeyObject; + try { + secret = createPrivateKey({ key: pem, format: "pem" }); + } catch { + throw new PublishError("key-invalid"); + } + return signerOf(secret); +} + +export function privateKeyPem(signer: Signer): string { + return signer.secret.export({ type: "pkcs8", format: "pem" }).toString(); +} + +export function publicKeyDocument(signer: Signer): PublicKeyDocument { + return Object.freeze({ + schemaVersion: 1, + keyid: signer.key.keyID, + keytype: "ed25519", + scheme: "ed25519", + keyval: Object.freeze({ public: signer.key.keyVal.public as string }), + }); +} + +/** Parse `.public.json`; the key id must be the derived one, so a + * document can never bind a foreign id to a key. + */ +export function keyFromDocument(value: unknown): Key { + const record = value as Partial | null; + const hex = record?.keyval?.public; + if ( + !record || + typeof record !== "object" || + record.schemaVersion !== 1 || + record.keytype !== "ed25519" || + record.scheme !== "ed25519" || + typeof hex !== "string" || + !/^[0-9a-f]{64}$/.test(hex) || + record.keyid !== keyIdOf(hex) + ) + throw new PublishError("key-invalid"); + return tufKey(hex); +} + +export function signatureOf(signer: Signer, bytes: Buffer): Signature { + return new Signature({ + keyID: signer.key.keyID, + sig: sign(null, bytes, signer.secret).toString("hex"), + }); +} diff --git a/src/publish/metadata.ts b/src/publish/metadata.ts new file mode 100644 index 00000000..7c217b22 --- /dev/null +++ b/src/publish/metadata.ts @@ -0,0 +1,230 @@ +import { createHash } from "node:crypto"; +import { + type Key, + Metadata, + MetadataKind, + MetaFile, + Root, + Snapshot, + TargetFile, + Targets, + Timestamp, +} from "@tufjs/models"; +import { parseUniqueJson } from "../providers/unique-json"; +import { PublishError } from "./errors"; +import { type RoleName, roleNames, type Signer, signatureOf } from "./keys"; + +/** Pure builders of signed TUF metadata and the names of a consistent-snapshot + * repository. The publisher and the loopback fixture + * (`scripts/update-fixture.ts`) both build every byte through this module, so + * what tests prove about the client holds for what the publisher writes. + */ +export const sha256 = (bytes: Uint8Array) => + createHash("sha256").update(bytes).digest("hex"); + +/** Metadata lifetimes in days (docs/release-cycle.md: policy, not TUF + * defaults). Configurable per call; these are the defaults. + */ +export type Lifetimes = Readonly>; +export const defaultLifetimeDays: Lifetimes = Object.freeze({ + root: 365, + targets: 90, + snapshot: 30, + timestamp: 7, +}); + +const dayMs = 86_400_000; + +/** `YYYY-MM-DDTHH:MM:SSZ`, the specification's date-time form. */ +export function expiryAfter(now: Date, days: number): string { + if (!Number.isFinite(days) || days <= 0) + throw new PublishError("invalid-input", "lifetime"); + return new Date(now.getTime() + days * dayMs) + .toISOString() + .replace(/\.\d{3}Z$/, "Z"); +} + +/** Tree paths. Everything except `timestampPath` is immutable once written. */ +export const timestampPath = "metadata/timestamp.json"; +export const metadataPath = (role: RoleName, version: number) => + role === "timestamp" ? timestampPath : `metadata/${version}.${role}.json`; + +/** Consistent-snapshot name of a target: `/.`. */ +export function consistentTargetPath(path: string, digest: string): string { + const slash = path.lastIndexOf("/"); + return `targets/${path.slice(0, slash + 1)}${digest}.${path.slice(slash + 1)}`; +} + +export function signMetadata( + signed: Root | Targets | Snapshot | Timestamp, + signers: readonly Signer[], +): Buffer { + if (signers.length === 0) throw new PublishError("key-missing", signed.type); + const metadata = new Metadata(signed); + for (const signer of signers) + metadata.sign((bytes) => signatureOf(signer, bytes), true); + return Buffer.from(JSON.stringify(metadata.toJSON())); +} + +const signedFields = (version: number, expires: string) => { + if (!Number.isSafeInteger(version) || version < 1) + throw new PublishError("invalid-input", "version"); + return { version, specVersion: "1.0.0", expires }; +}; + +/** Root metadata naming one or more keys per role, threshold one (the pilot + * policy of docs/release-cycle.md; docs/release-keys.md discloses the risk). + */ +export function buildRoot(input: { + version: number; + expires: string; + keys: Readonly>; + signers: readonly Signer[]; +}): Buffer { + const root = new Root({ + ...signedFields(input.version, input.expires), + consistentSnapshot: true, + }); + for (const role of roleNames) { + if (input.keys[role].length === 0) + throw new PublishError("key-missing", role); + for (const key of input.keys[role]) root.addKey(key, role); + } + return signMetadata(root, input.signers); +} + +/** One signed target. `custom` is part of the signed bytes; the client reads + * `custom.url` as the download location of an artifact. + */ +export type TargetEntry = Readonly<{ + length: number; + sha256: string; + custom?: Readonly>; +}>; + +export function buildTargets(input: { + version: number; + expires: string; + targets: ReadonlyMap; + signer: Signer; +}): Buffer { + const files: Record = {}; + for (const path of [...input.targets.keys()].sort()) { + const entry = input.targets.get(path) as TargetEntry; + files[path] = new TargetFile({ + path, + length: entry.length, + hashes: { sha256: entry.sha256 }, + ...(entry.custom ? { unrecognizedFields: { custom: entry.custom } } : {}), + }); + } + return signMetadata( + new Targets({ + ...signedFields(input.version, input.expires), + targets: files, + }), + [input.signer], + ); +} + +const metaFile = (version: number, bytes: Buffer) => + new MetaFile({ + version, + length: bytes.length, + hashes: { sha256: sha256(bytes) }, + }); + +export function buildSnapshot(input: { + version: number; + expires: string; + targetsVersion: number; + targetsBytes: Buffer; + /** Further `snapshot.meta` entries, file name → version. The publisher has + * no delegations and passes none; the fixture uses it to prove the client's + * rule that an entry, once listed, never disappears or goes down. */ + extraMeta?: Readonly>; + signer: Signer; +}): Buffer { + return signMetadata( + new Snapshot({ + ...signedFields(input.version, input.expires), + meta: { + "targets.json": metaFile(input.targetsVersion, input.targetsBytes), + ...Object.fromEntries( + Object.entries(input.extraMeta ?? {}).map(([name, version]) => [ + name, + new MetaFile({ version }), + ]), + ), + }, + }), + [input.signer], + ); +} + +export function buildTimestamp(input: { + version: number; + expires: string; + snapshotVersion: number; + snapshotBytes: Buffer; + signer: Signer; +}): Buffer { + return signMetadata( + new Timestamp({ + ...signedFields(input.version, input.expires), + snapshotMeta: metaFile(input.snapshotVersion, input.snapshotBytes), + }), + [input.signer], + ); +} + +const kinds = { + root: MetadataKind.Root, + targets: MetadataKind.Targets, + snapshot: MetadataKind.Snapshot, + timestamp: MetadataKind.Timestamp, +} as const; + +type Parsed = { + root: Metadata; + targets: Metadata; + snapshot: Metadata; + timestamp: Metadata; +}; + +/** Parse published metadata of the named role; shape only, no signatures. */ +export function parseMetadata( + role: R, + bytes: Uint8Array, +): Parsed[R] { + try { + const value = parseUniqueJson( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ); + const parse = Metadata.fromJSON as ( + kind: MetadataKind, + data: unknown, + ) => Parsed[R]; + const metadata = parse(kinds[role], value); + if (metadata.signed.type !== kinds[role]) throw new Error("kind"); + return metadata; + } catch { + throw new PublishError("repository-invalid", role); + } +} + +/** Whether `metadata` carries the threshold of valid signatures `root` + * requires for `role` — the same primitive the client verifies with. + */ +export function verifiesUnder( + root: Metadata, + role: RoleName, + metadata: Parsed[RoleName], +): boolean { + try { + root.verifyDelegate(role, metadata); + return true; + } catch { + return false; + } +} diff --git a/src/publish/monotonic.ts b/src/publish/monotonic.ts new file mode 100644 index 00000000..62767634 --- /dev/null +++ b/src/publish/monotonic.ts @@ -0,0 +1,79 @@ +import { + emptyFloors, + type FloorVector, + type FloorViolation, + factsOf, + mergeFloors, + type RoleFacts, + violationOf, +} from "../update/floors"; +import { loadRepository, type Repository, type Tree } from "./repository"; + +/** What an installed Lazurio refuses as `metadata-rollback`, asked BEFORE a + * tree is published. There is one definition of "backwards" — the client's + * floor vector (`src/update/floors.ts`, docs/update.md "The floor vector") — + * and this module only feeds it: the facts of the currently published tree + * become a vector, exactly as a Machine that has seen that tree holds it, and + * every role of the candidate tree is held against it with the client's own + * `violationOf`. Nothing here restates a rule. + * + * The publisher's operations cannot produce a violation by construction (every + * rebuilt role gets the next version, `snapshot.meta` always lists + * `targets.json`, nothing is removed). This is the check on the INPUT they + * build on: a branch that was reset to an older commit would make "the next + * version" a version clients have already seen with other content. + */ +function facts(repository: Repository): RoleFacts[] { + const text = (loaded: { bytes: Buffer }) => loaded.bytes.toString("utf8"); + return [ + ...repository.roots.map((root) => + factsOf(`${root.version}.root.json`, text(root)), + ), + ...(repository.timestamp + ? [factsOf("timestamp.json", text(repository.timestamp))] + : []), + ...(repository.snapshot + ? [factsOf("snapshot.json", text(repository.snapshot))] + : []), + ...(repository.targets + ? [factsOf("targets.json", text(repository.targets))] + : []), + ]; +} + +/** The vector of a Machine whose last successful check saw this tree. */ +export async function floorsOf(tree: Tree): Promise { + const repository = await loadRepository(tree); + return repository ? mergeFloors(emptyFloors, facts(repository)) : emptyFloors; +} + +/** Every rule the candidate tree would break for a Machine that has seen the + * published one. Empty: safe to publish. Both trees are verified as + * repositories first (`loadRepository`), so this is never asked of metadata + * nobody authenticated. + */ +export async function rollbackViolations( + published: Tree, + candidate: Tree, +): Promise { + const vector = await floorsOf(published); + const repository = await loadRepository(candidate); + if (!repository) return []; + const violations: FloorViolation[] = []; + for (const candidateFacts of facts(repository)) { + const violation = violationOf(vector, candidateFacts); + if (violation) violations.push(violation); + } + // A published root the candidate no longer holds breaks the chain clients + // walk one link at a time. + const held = new Set(repository.roots.map((root) => String(root.version))); + for (const version of Object.keys(vector.root.retained)) + if (!held.has(version)) + violations.push({ + role: "root", + rule: "root", + floor: Number(version), + offered: 0, + }); + return violations; +} diff --git a/src/publish/repository.ts b/src/publish/repository.ts new file mode 100644 index 00000000..3e672dba --- /dev/null +++ b/src/publish/repository.ts @@ -0,0 +1,777 @@ +import type { + Metadata, + Root, + Snapshot, + Targets, + Timestamp, +} from "@tufjs/models"; +import { + type ChannelDocument, + channelTargetPath, + parseChannelDocument, + type UpdateChannel, +} from "../update/channel"; +import { isProductVersion, updateTargets } from "../update/identity"; +import { + identityTargetPath, + maxIdentityBytes, + parseSignedIdentity, +} from "../update/signed-identity"; +import { compareVersions } from "../update/version"; +import { channelDocumentBytes, sameRelease } from "./channel-document"; +import { PublishError } from "./errors"; +import { type RoleName, roleNames, type Signer } from "./keys"; +import { + buildSnapshot, + buildTargets, + buildTimestamp, + consistentTargetPath, + defaultLifetimeDays, + expiryAfter, + type Lifetimes, + metadataPath, + parseMetadata, + sha256, + type TargetEntry, + timestampPath, + verifiesUnder, +} from "./metadata"; + +/** The publisher core (docs/update.md "Publishing"): every operation reads the + * published tree and returns a PLAN — the files to add, in order. It performs + * no I/O of its own beyond the `Tree` reader it is given, touches no network, + * and never plans to change an immutable file. `applyPlan` (`tree.ts`) is the + * only writer. + * + * The repository uses consistent snapshots: + * + * metadata/.root.json, .targets.json, .snapshot.json immutable + * metadata/timestamp.json the ONE mutable file + * targets//. immutable + * + * Nothing is ever deleted, so a timestamp somebody still holds — a CDN cache, + * an interrupted client — always describes a complete repository. + */ +export type Tree = Readonly<{ + read(path: string): Promise; + /** File names directly inside `directory`; empty when it does not exist. */ + list(directory: string): Promise; +}>; + +/** One file of a plan: literal bytes, or a local file to copy (an executable + * published inside the tree) whose length and digest the plan already names. + */ +export type PlannedWrite = Readonly< + { path: string } & ({ bytes: Buffer } | { file: string; sha256: string }) +>; + +export type Plan = Readonly<{ + /** In order; `metadata/timestamp.json`, when present, is last. */ + writes: readonly PlannedWrite[]; + result: Result; +}>; + +type Loaded = Readonly<{ version: number; bytes: Buffer; metadata: M }>; + +export type Repository = Readonly<{ + /** The verified chain held by the tree, ascending; the last one is current. */ + roots: readonly Loaded>[]; + timestamp: Loaded> | undefined; + snapshot: Loaded> | undefined; + targets: Loaded> | undefined; +}>; + +export type PublishOptions = Readonly<{ + signers: Readonly>>; + now: Date; + lifetimes?: Partial; + /** Root to install first: the initial root of an empty tree, or the + * successor of the published one. A root the tree already holds is a no-op. + */ + root?: Uint8Array; +}>; + +const currentRoot = (repository: Repository) => + repository.roots.at(-1) as Loaded>; + +function verifiedLink( + previous: Metadata | undefined, + next: Metadata, +): boolean { + return ( + next.signed.consistentSnapshot && + verifiesUnder(next, "root", next) && + (previous === undefined || + (next.signed.version === previous.signed.version + 1 && + verifiesUnder(previous, "root", next))) + ); +} + +/** Read and VERIFY what the tree publishes, as strictly as a client would. + * + * The tree is storage, not authority: whoever can write to the branch can put + * anything there. Everything a plan builds on — above all the target entries + * that the next run signs again with the real targets key — must therefore + * carry valid signatures under the PUBLISHED current root. Metadata that does + * not is refused (`repository-invalid`), never adopted and re-signed. Re-signing + * happens only when the caller offers a legitimate successor root that replaces + * a key, and then from content that verified under the root before it. + * + * A numbered targets or snapshot file above the referenced version is refused + * as well: it is either the leftover of an interrupted local run, which a + * person removes, or a sign that the timestamp was pointed back at an older + * state. + */ +export async function loadRepository( + tree: Tree, +): Promise { + const names = await tree.list("metadata"); + const numbered = (role: string) => + names + .map((name) => new RegExp(`^([1-9]\\d*)\\.${role}\\.json$`).exec(name)) + .filter((match) => match !== null) + .map((match) => Number(match[1])) + .sort((a, b) => a - b); + const rootVersions = numbered("root"); + if (rootVersions.length === 0) { + if (names.length > 0) throw new PublishError("repository-invalid", "root"); + return undefined; + } + const roots: Loaded>[] = []; + for (const version of rootVersions) { + const bytes = await tree.read(metadataPath("root", version)); + if (!bytes) throw new PublishError("repository-invalid", "root"); + const metadata = parseMetadata("root", bytes); + if ( + metadata.signed.version !== version || + !verifiedLink(roots.at(-1)?.metadata, metadata) + ) + throw new PublishError("repository-invalid", `${version}.root.json`); + roots.push({ version, bytes, metadata }); + } + const root = (roots.at(-1) as Loaded>).metadata; + const timestampBytes = await tree.read(timestampPath); + if (!timestampBytes) { + // Only a root: nothing was published yet, and nothing else may be here. + if (names.some((name) => !/^[1-9]\d*\.root\.json$/.test(name))) + throw new PublishError( + "repository-invalid", + "metadata without timestamp", + ); + return { + roots, + timestamp: undefined, + snapshot: undefined, + targets: undefined, + }; + } + const timestamp = parseMetadata("timestamp", timestampBytes); + if (!verifiesUnder(root, "timestamp", timestamp)) + throw new PublishError("repository-invalid", "timestamp signature"); + const linked = async ( + role: R, + meta: { version: number; length?: number; hashes?: Record }, + ) => { + const bytes = await tree.read(metadataPath(role, meta.version)); + const metadata = bytes ? parseMetadata(role, bytes) : undefined; + if ( + !bytes || + !metadata || + metadata.signed.version !== meta.version || + (meta.length !== undefined && meta.length !== bytes.length) || + (meta.hashes?.sha256 !== undefined && + meta.hashes.sha256 !== sha256(bytes)) + ) + throw new PublishError( + "repository-invalid", + `${meta.version}.${role}.json`, + ); + if (!verifiesUnder(root, role, metadata)) + throw new PublishError("repository-invalid", `${role} signature`); + const unreferenced = numbered(role).find( + (version) => version > meta.version, + ); + if (unreferenced !== undefined) + throw new PublishError( + "repository-invalid", + `unreferenced ${unreferenced}.${role}.json`, + ); + return { version: meta.version, bytes, metadata }; + }; + const snapshot = await linked("snapshot", timestamp.signed.snapshotMeta); + const targetsMeta = snapshot.metadata.signed.meta["targets.json"]; + if (!targetsMeta) throw new PublishError("repository-invalid", "snapshot"); + const targets = await linked("targets", targetsMeta); + return { + roots, + timestamp: { + version: timestamp.signed.version, + bytes: timestampBytes, + metadata: timestamp, + }, + snapshot, + targets, + }; +} + +function targetEntries(repository: Repository | undefined) { + const entries = new Map(); + const files = repository?.targets?.metadata.signed.targets ?? {}; + for (const [path, file] of Object.entries(files)) { + const digest = file.hashes.sha256; + if (digest === undefined) + throw new PublishError("repository-invalid", "targets"); + const url = file.custom.url; + entries.set(path, { + length: file.length, + sha256: digest, + ...(typeof url === "string" ? { custom: { url } } : {}), + }); + } + return entries; +} + +async function channelDocument( + tree: Tree, + entries: ReadonlyMap, + channel: UpdateChannel, +): Promise { + const path = channelTargetPath(channel); + const entry = entries.get(path); + if (!entry) return undefined; + const bytes = await tree.read(consistentTargetPath(path, entry.sha256)); + if (!bytes || sha256(bytes) !== entry.sha256) + throw new PublishError("repository-invalid", path); + try { + return parseChannelDocument(bytes, channel); + } catch { + throw new PublishError("repository-invalid", path); + } +} + +/** Decide the root of this plan and whether it has to be written. */ +function planRoot( + repository: Repository | undefined, + offered: Uint8Array | undefined, +): { root: Metadata; write: PlannedWrite | undefined } { + const current = repository ? currentRoot(repository) : undefined; + if (offered === undefined) { + if (!current) throw new PublishError("root-missing"); + return { root: current.metadata, write: undefined }; + } + const bytes = Buffer.from(offered); + const next = parseMetadata("root", bytes); + if (current && next.signed.version <= current.version) { + // A root the chain already holds — the current one, or an older one that + // a release tag from before a rotation still carries — changes nothing. + const published = repository?.roots.find( + (root) => root.version === next.signed.version, + ); + if (!published?.bytes.equals(bytes)) + throw new PublishError("root-chain", "published version, other bytes"); + return { root: current.metadata, write: undefined }; + } + if (!verifiedLink(current?.metadata, next)) + throw new PublishError( + "root-chain", + `published ${current?.version ?? "none"}, offered ${next.signed.version}`, + ); + return { + root: next, + write: { path: metadataPath("root", next.signed.version), bytes }, + }; +} + +type Change = Readonly<{ + targets?: ReadonlyMap; + files?: readonly PlannedWrite[]; + resign?: ReadonlySet<"targets" | "snapshot" | "timestamp">; +}>; + +/** The ONE place that turns a change into ordered writes: target files, root, + * targets, snapshot and the timestamp LAST. A role is rebuilt when its + * content changes, when the role above it was rebuilt, when asked, or when + * what is published — verified under the published root by `loadRepository` — + * does not verify under the successor root this plan installs. + */ +function assemble( + repository: Repository | undefined, + change: Change, + options: PublishOptions, +): PlannedWrite[] { + const { root, write: rootWrite } = planRoot(repository, options.root); + const lifetimes = { ...defaultLifetimeDays, ...options.lifetimes }; + const signerFor = (role: "targets" | "snapshot" | "timestamp") => { + const signer = options.signers[role]; + if (!signer) throw new PublishError("key-missing", role); + if (!root.signed.roles[role]?.keyIDs.includes(signer.key.keyID)) + throw new PublishError("key-unauthorized", role); + return signer; + }; + const stale = (role: "targets" | "snapshot" | "timestamp") => { + const published = repository?.[role]; + return ( + !published || + change.resign?.has(role) === true || + !verifiesUnder(root, role, published.metadata) + ); + }; + const writes: PlannedWrite[] = [...(change.files ?? [])]; + if (rootWrite) writes.push(rootWrite); + + let targets = repository?.targets; + const rebuiltTargets = change.targets !== undefined || stale("targets"); + if (rebuiltTargets) { + const version = (targets?.version ?? 0) + 1; + const bytes = buildTargets({ + version, + expires: expiryAfter(options.now, lifetimes.targets), + targets: change.targets ?? targetEntries(repository), + signer: signerFor("targets"), + }); + targets = { version, bytes, metadata: parseMetadata("targets", bytes) }; + writes.push({ path: metadataPath("targets", version), bytes }); + } + if (!targets) throw new PublishError("repository-invalid", "targets"); + + let snapshot = repository?.snapshot; + const rebuiltSnapshot = rebuiltTargets || stale("snapshot"); + if (rebuiltSnapshot) { + const version = (snapshot?.version ?? 0) + 1; + const bytes = buildSnapshot({ + version, + expires: expiryAfter(options.now, lifetimes.snapshot), + targetsVersion: targets.version, + targetsBytes: targets.bytes, + signer: signerFor("snapshot"), + }); + snapshot = { version, bytes, metadata: parseMetadata("snapshot", bytes) }; + writes.push({ path: metadataPath("snapshot", version), bytes }); + } + if (!snapshot) throw new PublishError("repository-invalid", "snapshot"); + + if (rebuiltSnapshot || stale("timestamp")) { + const bytes = buildTimestamp({ + version: (repository?.timestamp?.version ?? 0) + 1, + expires: expiryAfter(options.now, lifetimes.timestamp), + snapshotVersion: snapshot.version, + snapshotBytes: snapshot.bytes, + signer: signerFor("timestamp"), + }); + writes.push({ path: timestampPath, bytes }); + } + return writes; +} + +/** A built executable and what the build says about it. */ +export type ReleaseArtifact = Readonly<{ + target: string; + sha256: string; + length: number; + /** Exact bytes of the signed `identity.json`. */ + identity: Buffer; + /** Where clients download the executable (a release asset). Signed into the + * target's `custom.url`. Without it the executable is published inside the + * tree under its consistent-snapshot name and `file` must be given. */ + url?: string; + file?: string; +}>; + +export type ReleaseInput = Readonly<{ + channel: UpdateChannel; + version: string; + notes: string; + artifacts: readonly ReleaseArtifact[]; + /** Default: the channel's current one, else `0.0.1`. */ + minimumVersion?: string; + /** Accept `http://127.0.0.1` download locations (fixtures only). */ + allowLoopbackUrls?: boolean; +}>; + +/** Generated notes are bounded like every other small signed document. */ +export const maxReleaseNotesBytes = 256 * 1024; +export const releaseNotesPath = (version: string) => + `releases/${version}/notes.md`; + +function checkedUrl(value: string, allowLoopback: boolean): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new PublishError("invalid-input", "url"); + } + const loopback = + allowLoopback && url.protocol === "http:" && url.hostname === "127.0.0.1"; + if ( + (url.protocol !== "https:" && !loopback) || + url.username || + url.password || + url.search || + url.hash || + url.href !== value + ) + throw new PublishError("invalid-input", "url"); + return value; +} + +export type ReleaseResult = Readonly<{ + kind: "published" | "unchanged"; + channel: UpdateChannel; + version: string; + sequence: number; +}>; + +/** Add a release to a channel: signed artifacts, identities, notes and the new + * channel document. Publishing the same release again is a no-op, so a failed + * deployment can simply be repeated; a different release under a version the + * channel already reached is refused. + */ +export async function addRelease( + tree: Tree, + input: ReleaseInput, + options: PublishOptions, +): Promise> { + const repository = await loadRepository(tree); + const entries = targetEntries(repository); + if (!isProductVersion(input.version)) + throw new PublishError("invalid-input", "version"); + if (input.artifacts.length === 0) + throw new PublishError("invalid-input", "artifacts"); + const notes = Buffer.from(input.notes, "utf8"); + if (notes.length > maxReleaseNotesBytes) + throw new PublishError("invalid-input", "notes"); + + const documentTargets: Record = {}; + const files: PlannedWrite[] = []; + const put = (path: string, entry: TargetEntry, write?: PlannedWrite) => { + const published = entries.get(path); + if ( + published && + (published.length !== entry.length || + published.sha256 !== entry.sha256 || + published.custom?.url !== entry.custom?.url) + ) + throw new PublishError("immutable-conflict", path); + entries.set(path, entry); + if (write) files.push(write); + }; + for (const artifact of input.artifacts) { + if ( + !(updateTargets as readonly string[]).includes(artifact.target) || + documentTargets[artifact.target] !== undefined || + !/^[a-f0-9]{64}$/.test(artifact.sha256) || + !Number.isSafeInteger(artifact.length) || + artifact.length < 1 || + artifact.identity.length > maxIdentityBytes + ) + throw new PublishError("invalid-input", `artifact ${artifact.target}`); + // The client holds the signed identity against the signed artifact and the + // channel's version; a release that would fail there is never signed. + let identity: ReturnType; + try { + identity = parseSignedIdentity(artifact.identity); + } catch { + throw new PublishError("invalid-input", `identity ${artifact.target}`); + } + if ( + identity.target !== artifact.target || + identity.version !== input.version || + identity.artifactSha256 !== artifact.sha256 || + identity.artifactBytes !== artifact.length + ) + throw new PublishError("invalid-input", `identity ${artifact.target}`); + const path = `artifacts/${artifact.sha256}/lazurio`; + if (artifact.url !== undefined) + put(path, { + length: artifact.length, + sha256: artifact.sha256, + custom: { + url: checkedUrl(artifact.url, input.allowLoopbackUrls === true), + }, + }); + else if (artifact.file !== undefined) + put( + path, + { length: artifact.length, sha256: artifact.sha256 }, + { + path: consistentTargetPath(path, artifact.sha256), + file: artifact.file, + sha256: artifact.sha256, + }, + ); + else throw new PublishError("invalid-input", `artifact ${artifact.target}`); + const identityPath = identityTargetPath(path); + const identityDigest = sha256(artifact.identity); + put( + identityPath, + { length: artifact.identity.length, sha256: identityDigest }, + { + path: consistentTargetPath(identityPath, identityDigest), + bytes: artifact.identity, + }, + ); + documentTargets[artifact.target] = path; + } + + const current = await channelDocument(tree, entries, input.channel); + if (current) { + if (sameRelease(current, input.version, documentTargets)) + return { + writes: [], + result: { + kind: "unchanged", + channel: input.channel, + version: current.version, + sequence: current.sequence, + }, + }; + if (compareVersions(input.version, current.version) <= 0) + throw new PublishError( + "not-newer", + `${input.channel} offers ${current.version}`, + ); + for (const target of Object.keys(current.targets)) + if (documentTargets[target] === undefined) + throw new PublishError("target-dropped", target); + } + const notesPath = releaseNotesPath(input.version); + const notesDigest = sha256(notes); + put( + notesPath, + { length: notes.length, sha256: notesDigest }, + { path: consistentTargetPath(notesPath, notesDigest), bytes: notes }, + ); + const sequence = (current?.sequence ?? 0) + 1; + const document = channelDocumentBytes({ + channel: input.channel, + sequence, + version: input.version, + minimumVersion: input.minimumVersion ?? current?.minimumVersion ?? "0.0.1", + targets: documentTargets, + }); + const documentPath = channelTargetPath(input.channel); + const documentDigest = sha256(document); + // The channel path is the one target whose entry is replaced by design. + entries.set(documentPath, { + length: document.length, + sha256: documentDigest, + }); + files.push({ + path: consistentTargetPath(documentPath, documentDigest), + bytes: document, + }); + return { + writes: assemble(repository, { targets: entries, files }, options), + result: { + kind: "published", + channel: input.channel, + version: input.version, + sequence, + }, + }; +} + +/** Promote `preview` to `stable`: a new signed `stable` document that names + * the SAME artifact paths — the same digests, never a rebuild. The exact + * version is part of the request, so an approval can never promote something + * newer than what was reviewed. Pre-releases are never promoted. + * + * The signed `minimumVersion` of `stable` — below it an installed Lazurio + * shows its prominent notice — changes ONLY through the explicit + * `minimumVersion` of this request. It is never taken from `preview` and never + * derived; without the input the current value is carried over. It can only + * rise, and never above the version being promoted: a floor no published + * version can reach would put every Machine below it for good. Promoting the + * version `stable` already offers, with a higher floor, is how the floor is + * raised between releases. + */ +export async function promote( + tree: Tree, + input: Readonly<{ version: string; minimumVersion?: string }>, + options: PublishOptions, +): Promise> { + const repository = await loadRepository(tree); + const entries = targetEntries(repository); + const preview = await channelDocument(tree, entries, "preview"); + if (!preview || preview.version !== input.version) + throw new PublishError( + "version-mismatch", + `preview offers ${preview?.version ?? "nothing"}`, + ); + if (input.version.includes("-")) + throw new PublishError("invalid-input", "pre-release"); + const stable = await channelDocument(tree, entries, "stable"); + const minimumVersion = + input.minimumVersion ?? stable?.minimumVersion ?? "0.0.1"; + if ( + !isProductVersion(minimumVersion) || + compareVersions(minimumVersion, preview.version) > 0 || + (stable && compareVersions(minimumVersion, stable.minimumVersion) < 0) + ) + throw new PublishError("invalid-input", "minimum-version"); + if (stable) { + const same = sameRelease(stable, preview.version, preview.targets); + if (same && stable.minimumVersion === minimumVersion) + return { + writes: [], + result: { + kind: "unchanged", + channel: "stable", + version: stable.version, + sequence: stable.sequence, + }, + }; + if (!same && compareVersions(preview.version, stable.version) <= 0) + throw new PublishError("not-newer", `stable offers ${stable.version}`); + for (const target of Object.keys(stable.targets)) + if (preview.targets[target] === undefined) + throw new PublishError("target-dropped", target); + } + const sequence = (stable?.sequence ?? 0) + 1; + const document = channelDocumentBytes({ + channel: "stable", + sequence, + version: preview.version, + minimumVersion, + targets: preview.targets, + }); + const path = channelTargetPath("stable"); + const digest = sha256(document); + entries.set(path, { length: document.length, sha256: digest }); + return { + writes: assemble( + repository, + { + targets: entries, + files: [{ path: consistentTargetPath(path, digest), bytes: document }], + }, + options, + ), + result: { + kind: "published", + channel: "stable", + version: preview.version, + sequence, + }, + }; +} + +export type RefreshRole = "targets" | "snapshot" | "timestamp"; + +/** Re-sign roles with a fresh expiry and the next version; no target changes. + * A role below a re-signed one is re-signed with it (its content names the + * bytes above). With no role named, only what the (new) root requires is + * rebuilt — that is how a rotated root is installed. + */ +export async function refresh( + tree: Tree, + roles: readonly RefreshRole[], + options: PublishOptions, +): Promise>> { + const repository = await loadRepository(tree); + const writes = assemble(repository, { resign: new Set(roles) }, options); + return { writes, result: { rewritten: writes.map((write) => write.path) } }; +} + +/** Days of remaining validity below which a role counts as low. Snapshot and + * timestamp margins are when the scheduled job renews them; targets and root + * margins are when a person with the protected keys must act. + */ +export type Margins = Readonly>; +export const defaultMarginDays: Margins = Object.freeze({ + root: 60, + targets: 30, + snapshot: 20, + timestamp: 5, +}); + +export type RoleStatus = Readonly<{ + role: RoleName; + version: number; + expires: string; + remainingDays: number; + marginDays: number; + low: boolean; +}>; + +export function repositoryStatus( + repository: Repository, + now: Date, + margins: Partial = {}, +): Readonly<{ roles: readonly RoleStatus[]; low: readonly RoleName[] }> { + const limits = { ...defaultMarginDays, ...margins }; + const roles: RoleStatus[] = []; + for (const role of roleNames) { + const loaded = role === "root" ? currentRoot(repository) : repository[role]; + if (!loaded) throw new PublishError("repository-invalid", role); + const expires = loaded.metadata.signed.expires; + const remainingDays = + Math.floor(((Date.parse(expires) - now.getTime()) / 86_400_000) * 100) / + 100; + if (!Number.isFinite(remainingDays)) + throw new PublishError("repository-invalid", role); + roles.push({ + role, + version: loaded.version, + expires, + remainingDays, + marginDays: limits[role], + low: remainingDays < limits[role], + }); + } + return { + roles, + low: roles.filter((entry) => entry.low).map((entry) => entry.role), + }; +} + +/** Every object the CURRENT metadata references: the numbered chain, and for + * each signed target either its file inside the tree or its signed URL. The + * caller proves the URLs over the network; this proves the tree. + */ +export async function referencedObjects(tree: Tree): Promise< + Readonly<{ + external: readonly Readonly<{ + path: string; + url: string; + length: number; + sha256: string; + }>[]; + /** Artifact paths the channel documents select right now. */ + selected: readonly string[]; + }> +> { + // Signatures of every role were verified by `loadRepository`. + const repository = await loadRepository(tree); + if (!repository?.targets) throw new PublishError("repository-invalid"); + const entries = targetEntries(repository); + const external = []; + for (const [path, entry] of entries) { + if (entry.custom?.url !== undefined) { + external.push({ + path, + url: entry.custom.url, + length: entry.length, + sha256: entry.sha256, + }); + continue; + } + const bytes = await tree.read(consistentTargetPath(path, entry.sha256)); + if ( + !bytes || + bytes.length !== entry.length || + sha256(bytes) !== entry.sha256 + ) + throw new PublishError("unreachable", path); + } + const selected = new Set(); + for (const channel of ["stable", "preview"] as const) { + const document = await channelDocument(tree, entries, channel); + for (const path of Object.values(document?.targets ?? {})) { + if (!entries.has(path) || !entries.has(identityTargetPath(path))) + throw new PublishError("unreachable", path); + selected.add(path); + } + } + return { external, selected: [...selected].sort() }; +} diff --git a/src/publish/root.ts b/src/publish/root.ts new file mode 100644 index 00000000..9c720ef7 --- /dev/null +++ b/src/publish/root.ts @@ -0,0 +1,89 @@ +import type { Key } from "@tufjs/models"; +import { PublishError } from "./errors"; +import type { RoleName, Signer } from "./keys"; +import { + buildRoot, + defaultLifetimeDays, + expiryAfter, + parseMetadata, + verifiesUnder, +} from "./metadata"; + +/** Root metadata is made OFFLINE by the holder of the root key + * (docs/release-keys.md); these functions are what `scripts/release-keys.ts` + * runs there. The publishing workflows only ever install the result. + */ +export type RoleKeys = Readonly>; + +/** Root version 1, signed by the root key it names. */ +export function initialRoot(input: { + keys: RoleKeys; + rootSigner: Signer; + now: Date; + lifetimeDays?: number; +}): Buffer { + if (!input.keys.root.some((key) => key.keyID === input.rootSigner.key.keyID)) + throw new PublishError("key-unauthorized", "root"); + return buildRoot({ + version: 1, + expires: expiryAfter( + input.now, + input.lifetimeDays ?? defaultLifetimeDays.root, + ), + keys: input.keys, + signers: [input.rootSigner], + }); +} + +/** Root N+1. It is signed by the outgoing root key(s) — so every installation + * that trusts root N accepts it — and by the incoming ones it names. The same + * call with unchanged keys renews an expiring root. The result is verified + * exactly as a client will verify it before it is returned. + */ +export function nextRoot(input: { + current: Uint8Array; + keys: RoleKeys; + /** Outgoing and incoming root signers; one signer when the key stays. */ + rootSigners: readonly Signer[]; + now: Date; + lifetimeDays?: number; +}): Buffer { + const current = parseMetadata("root", input.current); + if (!verifiesUnder(current, "root", current)) + throw new PublishError("root-chain", "current root does not verify"); + const unique = new Map( + input.rootSigners.map((signer) => [signer.key.keyID, signer]), + ); + const bytes = buildRoot({ + version: current.signed.version + 1, + expires: expiryAfter( + input.now, + input.lifetimeDays ?? defaultLifetimeDays.root, + ), + keys: input.keys, + signers: [...unique.values()], + }); + const next = parseMetadata("root", bytes); + if (!verifiesUnder(current, "root", next)) + throw new PublishError("root-chain", "outgoing root key missing"); + if (!verifiesUnder(next, "root", next)) + throw new PublishError("root-chain", "incoming root key missing"); + return bytes; +} + +/** The keys a root names for each role, to carry unchanged roles over. */ +export function rootRoleKeys(rootBytes: Uint8Array): RoleKeys { + const root = parseMetadata("root", rootBytes).signed; + const keysOf = (role: RoleName) => + (root.roles[role]?.keyIDs ?? []).map((id) => { + const key = root.keys[id]; + if (!key) throw new PublishError("repository-invalid", "root keys"); + return key; + }); + return { + root: keysOf("root"), + targets: keysOf("targets"), + snapshot: keysOf("snapshot"), + timestamp: keysOf("timestamp"), + }; +} diff --git a/src/publish/tree.ts b/src/publish/tree.ts new file mode 100644 index 00000000..f8550c58 --- /dev/null +++ b/src/publish/tree.ts @@ -0,0 +1,127 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { + copyFile, + mkdir, + readdir, + readFile, + stat, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { writeDurableFile } from "../update/durable-file"; +import { PublishError } from "./errors"; +import { timestampPath } from "./metadata"; +import type { Plan, PlannedWrite, Tree } from "./repository"; + +/** Filesystem adapter of the publisher: a directory as the published tree. */ +const missing = (error: unknown) => + ["ENOENT", "ENOTDIR"].includes( + (error as NodeJS.ErrnoException | undefined)?.code ?? "", + ); + +export function directoryTree(directory: string): Tree { + return Object.freeze({ + async read(path: string) { + try { + return await readFile(join(directory, path)); + } catch (error) { + if (missing(error)) return undefined; + throw error; + } + }, + async list(path: string) { + try { + const entries = await readdir(join(directory, path), { + withFileTypes: true, + }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => entry.name); + } catch (error) { + if (missing(error)) return []; + throw error; + } + }, + }); +} + +export async function hashFile( + path: string, +): Promise> { + const hash = createHash("sha256"); + let length = 0; + for await (const chunk of createReadStream(path)) { + hash.update(chunk as Buffer); + length += (chunk as Buffer).length; + } + return { sha256: hash.digest("hex"), length }; +} + +const safePath = (path: string) => + /^(metadata|targets)\/[A-Za-z0-9._/+-]+$/.test(path) && + !path.split("/").some((part) => part === "" || part === "." || part === ".."); + +/** Apply a plan to the tree directory. + * + * Nothing immutable is ever rewritten: every planned path that already exists + * must hold exactly the planned bytes, and this is decided for the WHOLE plan + * before the first byte is written, so a refusal changes nothing. Files are + * then created exclusively, in plan order; `metadata/timestamp.json` — the + * one mutable file, and what makes the rest visible — is replaced last, by + * temporary file and rename. Nothing is deleted. + */ +export async function applyPlan( + directory: string, + plan: Pick, "writes">, +): Promise { + const { writes } = plan; + const timestampAt = writes.findIndex((write) => write.path === timestampPath); + if ( + writes.some((write) => !safePath(write.path)) || + new Set(writes.map((write) => write.path)).size !== writes.length || + (timestampAt !== -1 && timestampAt !== writes.length - 1) + ) + throw new PublishError("invalid-input", "plan"); + const pending: PlannedWrite[] = []; + for (const write of writes) { + const path = join(directory, write.path); + if (write.path === timestampPath) { + pending.push(write); + continue; + } + const exists = await stat(path).then( + (entry) => entry.isFile() || "other", + (error) => { + if (missing(error)) return false; + throw error; + }, + ); + if (exists === false) { + pending.push(write); + continue; + } + const same = + exists === true && + ("bytes" in write + ? write.bytes.equals(await readFile(path)) + : (await hashFile(path)).sha256 === write.sha256); + if (!same) throw new PublishError("immutable-conflict", write.path); + } + const written: string[] = []; + for (const write of pending) { + const path = join(directory, write.path); + await mkdir(dirname(path), { recursive: true }); + if (write.path === timestampPath && "bytes" in write) + await writeDurableFile(dirname(path), "timestamp.json", write.bytes); + else if ("bytes" in write) + await writeFile(path, write.bytes, { flag: "wx", mode: 0o644 }); + else { + if ((await hashFile(write.file)).sha256 !== write.sha256) + throw new PublishError("invalid-input", write.path); + await copyFile(write.file, path, 1 /* COPYFILE_EXCL */); + } + written.push(write.path); + } + return written; +} diff --git a/src/update/check.ts b/src/update/check.ts index e0848487..d0b35958 100644 --- a/src/update/check.ts +++ b/src/update/check.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { mkdir, readdir, readFile, rm } from "node:fs/promises"; +import { lstat, mkdir, readdir, readFile, rm } from "node:fs/promises"; import { isAbsolute, join, resolve } from "node:path"; import { type Fetcher, Updater } from "tuf-js"; import { ExpiredMetadataError } from "tuf-js/dist/error"; @@ -50,8 +50,11 @@ export type CheckInput = Readonly<{ targetBaseUrl: string; channel: UpdateChannel; identity: ProductIdentity; - /** Trust anchor for the very first check only; refused once `trust/` holds a - * root. The compiled-in root of a later slice enters through this input. */ + /** The SUPPLIED root: the root compiled into this executable + * (`defaults.ts`), or the caller's `--bootstrap-root`, which overrides it. + * Supplied on every run and never a conflict: it seeds empty trust, and + * beside durable trust it is ignored unless it is the verified direct + * successor of the trusted root (see `readSeed`). */ bootstrapRoot?: Uint8Array; /** Owns origins, redirects, size and time limits of every transfer. */ transport: Fetcher; @@ -88,6 +91,9 @@ export type SignedArtifact = Readonly<{ path: string; sha256: string; length: number; + /** Download location from the signed target's `custom.url` (a release + * asset). Absent: the repository serves the bytes itself. */ + url?: string; }>; /** What a step after the check works with: the refreshed client, whose @@ -134,9 +140,13 @@ function expired(error: unknown): boolean { function classify(error: unknown, fetcher: TrustFetcher): UpdateError { if (error instanceof UpdateFailure) return error.failure; if (fetcher.lastFailure) { - const { tooLarge, ...context } = fetcher.lastFailure; + const { tooLarge, originRefused, ...context } = fetcher.lastFailure; return updateError( - tooLarge ? "response-too-large" : "network-unavailable", + originRefused + ? "origin-refused" + : tooLarge + ? "response-too-large" + : "network-unavailable", context, ); } @@ -160,6 +170,25 @@ function validUrl(value: string): boolean { } } +/** A signed download location is still only a location: scheme, origin and + * redirects are the transport's policy, length and digest the download's. + */ +function validArtifactUrl(value: unknown): value is string { + if (typeof value !== "string") return false; + try { + const url = new URL(value); + return ( + ["https:", "http:"].includes(url.protocol) && + !url.username && + !url.password && + !url.hash && + url.href === value + ); + } catch { + return false; + } +} + /** Refresh in scratch. Throws on the first failure; the caller captures * whatever was verified before it. NO target is looked up here. */ @@ -238,6 +267,17 @@ export async function checkForUpdate( const operationId = input.operationId ?? randomUUID(); const trustDirectory = join(input.base, "trust"); const updateDirectory = join(input.base, "update"); + // No supplied root and no trust directory at all: there is nothing to + // verify with and nothing to repair. Refused before anything is created — a + // build without a compiled-in root leaves no trace on a Machine. + if ( + input.bootstrapRoot === undefined && + !(await lstat(trustDirectory).then( + () => true, + () => false, + )) + ) + return fail("trust-missing"); let lock: Awaited>; try { await mkdir(input.base, { recursive: true, mode: 0o700 }); @@ -354,9 +394,19 @@ async function checkUnderLock( return { failure: updateError("metadata-invalid", { subject: "artifact" }), }; + const url = artifact.custom.url; + if (url !== undefined && !validArtifactUrl(url)) + return { + failure: updateError("metadata-invalid", { subject: "artifact-url" }), + }; const verified: Verified = { document, - artifact: { path, sha256, length: artifact.length }, + artifact: { + path, + sha256, + length: artifact.length, + ...(url === undefined ? {} : { url }), + }, }; if ( !step || diff --git a/src/update/cli.ts b/src/update/cli.ts index 9bdaac91..3d1fe8a9 100644 --- a/src/update/cli.ts +++ b/src/update/cli.ts @@ -1,3 +1,4 @@ +import { mkdir } from "node:fs/promises"; import { isAbsolute, resolve } from "node:path"; import { parseArgs } from "node:util"; import { DistributionTransport } from "../distribution/transport"; @@ -13,8 +14,15 @@ import { parseServiceSpec } from "./activation-record"; import { resolveInstallBase } from "./base"; import { isUpdateChannel } from "./channel"; import { type CheckResult, checkForUpdate } from "./check"; -import { readUpdateConfig } from "./config"; +import { changeUpdateConfig, readUpdateConfig } from "./config"; +import { + defaultArtifactOrigins, + defaultMetadataBaseUrl, + defaultTargetBaseUrl, + embeddedTrustRoot, +} from "./defaults"; import { defaultDownloadPolicy } from "./download"; +import { writeDurableFile } from "./durable-file"; import { type ErrorContext, exitUpdateAvailable, @@ -30,8 +38,10 @@ import { performInstall, userUnitDirectory, } from "./install"; +import { layout } from "./layout"; import { type Observed, readObserved } from "./observed"; import { type ProcessRunner, selfCheckReport } from "./self-check"; +import { ensureOwnedDirectory } from "./trust"; import { performRollback, performUpdate, @@ -41,45 +51,65 @@ import { } from "./update"; /** `lazurio update …` and `lazurio --version`: terminal surface of the one - * update core (docs/update.md "Surfaces"). Origins, channel and the bootstrap - * root are explicit in this build; defaults arrive with the publisher. + * update core (docs/update.md "Surfaces"). The repository, the download + * origins and the trust root are compiled in (`defaults.ts`), the channel is + * read from `update/config.json`; every one of them has an explicit option + * for a fork, a mirror or a fixture. */ export const updateHelp = `--version [--json] Prints the version, source commit and target this executable was built with. -install --metadata-url --target-url - --channel [--bootstrap-root ] [--base ] +install [--channel ] [--base ] [--service systemd-user --folder [--unit ] - [--organization-directory [--bun-executable ]]] [--loopback-fixture] [--json] + [--organization-directory [--bun-executable ]]] + [] [--json] This executable installs ITSELF as the first version: it proves through signed metadata that its own bytes are a published artifact with the identity compiled into it, copies itself to versions/, and points bin/lazurio at it. + It downloads nothing but signed metadata and its own signed identity. Refused when an installation exists (already-installed): versions change through update. All or nothing; shell profiles are not edited. With --service (Linux) it writes, enables and starts a systemd user unit for the Launchpad (default lazurio-launchpad.service) and refuses a unit of that name it did not - write. The service and Folder are recorded, so update needs no flag for them. -update [--download-only] --metadata-url --target-url - --channel [--bootstrap-root ] [--base ] + write. The channel, the service and the Folder are recorded in + update/config.json, so update needs no flag for them. +, for install, update and update --check: + [--metadata-url --target-url + [--artifact-origin ]...] [--bootstrap-root ] + [--loopback-fixture] + The signed repository, the origins a download may touch and the trust root are + compiled into the executable; these flags only override them (a fork, a + mirror, a fixture). --metadata-url and --target-url come together and name + another repository as a whole: then only its origins and every + --artifact-origin are contacted, never the compiled-in ones. + --bootstrap-root replaces the compiled-in root as the supplied root. A supplied + root seeds trust while none exists; afterwards it is ignored unless it is the + verified direct successor of the trusted root. It is never a conflict. With no + compiled-in root and no --bootstrap-root the answer is trust-missing and + nothing is created. +update [--download-only] [--channel ] [--base ] [--folder ] [--service --unit ] - [--deadline-ms ] [--stability-ms ] [--loopback-fixture] [--json] + [--deadline-ms ] [--stability-ms ] [] [--json] Checks, downloads, verifies and stages the channel's version and activates it: the selector bin/lazurio is switched and the new version must confirm itself, otherwise the previous version is selected again. --download-only stops after staging. With --folder the candidate must prove it can read that Folder's state. --service systemd-user restarts the named user unit and waits for a fresh, stable Launchpad of the new version; none (default) manages no service. + Channel, service and Folder default to what update/config.json records. The activation deadline (120000 ms) and stability period (10000 ms) can be shortened for qualification runs. Exit 0 updated, staged or nothing to do; an error prints its stable code. update --check [--json] Verifies signed metadata and the channel document, records verified trust and - rewrites the observation. Downloads and activates nothing. The bootstrap root - seeds trust while none exists; afterwards it is ignored unless it is the - verified direct successor of the trusted root. + rewrites the observation. Downloads and activates nothing. Exit 0 up to date, 10 update available; an error prints its stable code. update rollback [--base ] [--folder ...] [--service ... --unit ...] [--json] Activates the version the last confirmed activation replaced, through the same confirmation. Refused when that version cannot read the current state schemas. +update channel [stable|preview] [--base ] [--json] + Prints the channel, or selects it for later checks; the recorded service and + Folder are kept. Switching never downgrades: a channel whose version is not + newer than the installed one reports up to date. update status [--base ] [--json] Prints the last observation without touching the network. self-check [--json] [--folder ] @@ -216,11 +246,95 @@ const positiveInteger = (value: string | undefined): number | undefined => ? Number(value) : undefined; +type RepositoryOptions = Readonly<{ + "metadata-url"?: string | undefined; + "target-url"?: string | undefined; + "artifact-origin"?: string[] | undefined; + "bootstrap-root"?: string | undefined; + "loopback-fixture"?: boolean | undefined; +}>; + +/** Where `install`, `update` and `update --check` look and whom they trust: + * the ONE place that turns the compiled-in defaults and their overriding + * flags into the inputs of a check. + * + * Another repository is named as a whole, and then ONLY what the caller named + * is contacted: a fixture or a mirror never falls back to the compiled-in + * origins, and the compiled-in repository never accepts extra origins. The + * supplied root is the caller's `--bootstrap-root`, else the compiled-in one, + * else none — and then a check without durable trust is `trust-missing`. + */ +async function resolveRepository( + values: RepositoryOptions, + timeoutMs: number, + embeddedRoot: Uint8Array | undefined = embeddedTrustRoot(), +): Promise< + | Readonly<{ invalid: string }> + | Readonly<{ + transport: DistributionTransport; + source: Readonly<{ + metadataBaseUrl: string; + targetBaseUrl: string; + bootstrapRoot?: Uint8Array; + }>; + }> +> { + const explicit = + values["metadata-url"] !== undefined || values["target-url"] !== undefined; + const metadataBaseUrl = explicit + ? values["metadata-url"] + : defaultMetadataBaseUrl; + const targetBaseUrl = explicit ? values["target-url"] : defaultTargetBaseUrl; + if ( + !metadataBaseUrl || + !targetBaseUrl || + (!explicit && values["artifact-origin"] !== undefined) + ) + return { invalid: "repository" }; + let transport: DistributionTransport; + try { + transport = new DistributionTransport( + [ + ...new Set([ + new URL(metadataBaseUrl).origin, + new URL(targetBaseUrl).origin, + ...(explicit + ? (values["artifact-origin"] ?? []) + : defaultArtifactOrigins), + ]), + ], + timeoutMs, + new AbortController().signal, + values["loopback-fixture"] === true, + ); + } catch { + return { invalid: "url" }; + } + let bootstrapRoot = embeddedRoot; + if (values["bootstrap-root"] !== undefined) + try { + // A trust anchor is read only from a caller-owned, non-shared file. + bootstrapRoot = await readOwnedDeclarationBytes(values["bootstrap-root"]); + } catch { + return { invalid: "bootstrap-root" }; + } + return { + transport, + source: { + metadataBaseUrl, + targetBaseUrl, + ...(bootstrapRoot === undefined ? {} : { bootstrapRoot }), + }, + }; +} + /** Never throws: every refusal is a typed result with a stable exit status. */ export async function runUpdateCommand( args: readonly string[], environment: Readonly<{ identity?: ProductIdentity; + /** Stands in for the compiled-in root (`defaults.ts`). */ + embeddedRoot?: Uint8Array; clock?: () => Date; platform?: string; env?: Readonly>; @@ -248,6 +362,7 @@ export async function runUpdateCommand( "target-url": { type: "string" }, channel: { type: "string" }, "bootstrap-root": { type: "string" }, + "artifact-origin": { type: "string", multiple: true }, base: { type: "string" }, folder: { type: "string" }, service: { type: "string" }, @@ -265,7 +380,7 @@ export async function runUpdateCommand( const supplied = new Set(); for (const token of tokens) { if (token.kind !== "option") continue; - if (supplied.has(token.name)) + if (supplied.has(token.name) && token.name !== "artifact-origin") return render(failure("invalid-request"), json); supplied.add(token.name); } @@ -394,6 +509,36 @@ export async function runUpdateCommand( code: 0, }; } + if (positionals[0] === "channel" && positionals.length <= 2) { + if (!only(["json", "base"])) + return render(failure("invalid-request"), json); + const wanted = positionals[1]; + if (wanted !== undefined) { + if (!isUpdateChannel(wanted)) + return render( + failure("invalid-request", { option: "channel" }), + json, + ); + try { + await mkdir(base, { recursive: true, mode: 0o700 }); + await ensureOwnedDirectory(layout(base).update); + await changeUpdateConfig(base, { channel: wanted }, writeDurableFile); + } catch { + return render( + failure("storage-unavailable", { stage: "config" }), + json, + ); + } + } + const selected = (await readUpdateConfig(base)).channel; + return { + code: 0, + stdout: json + ? JSON.stringify({ channel: selected }) + : `Channel: ${selected}`, + stderr: "", + }; + } if (positionals.length === 1 && positionals[0] === "status") { if (!only(["json", "base"])) return render(failure("invalid-request"), json); @@ -426,56 +571,33 @@ export async function runUpdateCommand( "target-url", "channel", "bootstrap-root", + "artifact-origin", "loopback-fixture", ...(checking ? ["check"] : ["download-only", ...serviceOptions]), ]) ) return render(failure("invalid-request"), json); - const channel = values.channel; - if ( - !isUpdateChannel(channel) || - !values["metadata-url"] || - !values["target-url"] - ) + const channel = values.channel ?? recorded.channel; + if (!isUpdateChannel(channel)) return render(failure("invalid-request"), json); - let transport: DistributionTransport; - try { - transport = new DistributionTransport( - [ - ...new Set([ - new URL(values["metadata-url"]).origin, - new URL(values["target-url"]).origin, - ]), - ], - // One transport per operation: a check is short, a download is - // bounded by its own deadline inside this outer limit. - checking ? 60_000 : defaultDownloadPolicy.deadlineMs + 300_000, - new AbortController().signal, - values["loopback-fixture"] === true, + const repository = await resolveRepository( + values, + // One transport per operation: a check is short, a download is bounded + // by its own deadline inside this outer limit. + checking ? 60_000 : defaultDownloadPolicy.deadlineMs + 300_000, + environment.embeddedRoot, + ); + if ("invalid" in repository) + return render( + failure("invalid-request", { option: repository.invalid }), + json, ); - } catch { - return render(failure("invalid-request", { option: "url" }), json); - } - let bootstrapRoot: Uint8Array | undefined; - if (values["bootstrap-root"] !== undefined) - try { - // A trust anchor is read only from a caller-owned, non-shared file. - bootstrapRoot = await readOwnedDeclarationBytes( - values["bootstrap-root"], - ); - } catch { - return render( - failure("invalid-request", { option: "bootstrap-root" }), - json, - ); - } + const { transport } = repository; const checkInput = { base, - metadataBaseUrl: values["metadata-url"], - targetBaseUrl: values["target-url"], channel, identity: environment.identity ?? embeddedIdentity(), - ...(bootstrapRoot === undefined ? {} : { bootstrapRoot }), + ...repository.source, transport, clock, }; @@ -531,6 +653,8 @@ export async function runInstallCommand( args: readonly string[], environment: Readonly<{ identity?: ProductIdentity; + /** Stands in for the compiled-in root (`defaults.ts`). */ + embeddedRoot?: Uint8Array; clock?: () => Date; platform?: string; env?: Readonly>; @@ -553,6 +677,7 @@ export async function runInstallCommand( "target-url": { type: "string" }, channel: { type: "string" }, "bootstrap-root": { type: "string" }, + "artifact-origin": { type: "string", multiple: true }, base: { type: "string" }, "loopback-fixture": { type: "boolean" }, service: { type: "string" }, @@ -566,20 +691,22 @@ export async function runInstallCommand( const names: string[] = tokens.flatMap((token) => token.kind === "option" ? [token.name] : [], ); - if (new Set(names).size !== names.length) return refuse("invalid-request"); + if ( + new Set(names).size !== + names.filter((name) => name !== "artifact-origin").length + + (names.includes("artifact-origin") ? 1 : 0) + ) + return refuse("invalid-request"); const env = environment.env ?? process.env; const platform = environment.platform ?? process.platform; const base = values.base ?? resolveInstallBase({ platform, env, homedir: env.HOME }); if (!base || !isAbsolute(base) || resolve(base) !== base) return refuse("invalid-request", { option: "base" }); - const channel = values.channel; - if ( - !isUpdateChannel(channel) || - !values["metadata-url"] || - !values["target-url"] - ) - return refuse("invalid-request"); + // A channel chosen before the installation is kept; a flag overrides it + // and is what gets recorded. + const channel = values.channel ?? (await readUpdateConfig(base)).channel; + if (!isUpdateChannel(channel)) return refuse("invalid-request"); // The Launchpad's arguments belong to the service and to nothing else. const wantsService = values.service !== undefined; const unitDirectory = userUnitDirectory(env); @@ -596,40 +723,20 @@ export async function runInstallCommand( )) ) return refuse("invalid-request", { option: "service" }); - let transport: DistributionTransport; - try { - transport = new DistributionTransport( - [ - ...new Set([ - new URL(values["metadata-url"]).origin, - new URL(values["target-url"]).origin, - ]), - ], - 120_000, - new AbortController().signal, - values["loopback-fixture"] === true, - ); - } catch { - return refuse("invalid-request", { option: "url" }); - } - let bootstrapRoot: Uint8Array | undefined; - if (values["bootstrap-root"] !== undefined) - try { - bootstrapRoot = await readOwnedDeclarationBytes( - values["bootstrap-root"], - ); - } catch { - return refuse("invalid-request", { option: "bootstrap-root" }); - } + const repository = await resolveRepository( + values, + 120_000, + environment.embeddedRoot, + ); + if ("invalid" in repository) + return refuse("invalid-request", { option: repository.invalid }); return renderInstall( await performInstall({ base, - metadataBaseUrl: values["metadata-url"], - targetBaseUrl: values["target-url"], channel, identity: environment.identity ?? embeddedIdentity(), - ...(bootstrapRoot === undefined ? {} : { bootstrapRoot }), - transport, + ...repository.source, + transport: repository.transport, clock: environment.clock ?? (() => new Date()), executable: environment.executable ?? process.execPath, platform, diff --git a/src/update/config.ts b/src/update/config.ts index ca3c98d6..be065800 100644 --- a/src/update/config.ts +++ b/src/update/config.ts @@ -1,22 +1,35 @@ import { readFile } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { parseServiceSpec, type ServiceSpec } from "./activation-record"; +import { isUpdateChannel, type UpdateChannel } from "./channel"; +import { defaultChannel } from "./defaults"; import type { DurableWriter } from "./durable-file"; import { layout } from "./layout"; -/** `update/config.json`: what `lazurio install` decided about THIS Machine, so - * that `lazurio update` needs no flag for it — which supervisor owns the - * Launchpad, and which Folder a candidate must prove it can read. It is - * tolerant state: missing or damaged means the defaults (no managed service, - * no Folder check), never an error, and a flag always overrides it. +/** `update/config.json`: the ONE owned file of local update choices, so that + * `lazurio update` needs no flag for them — + * + * channel which channel a check reads (`lazurio update channel `); + * service which supervisor owns the Launchpad (`lazurio install`); + * folder which Folder a candidate must prove it can read (`install`). + * + * It selects nothing that runs and carries no trust. It is tolerant state, + * field by field: a missing or damaged file, another schema version, or one + * unusable member means the default for what cannot be read — `stable`, no + * managed service, no Folder check — never an error, and a flag always + * overrides it. There is one reader and one writer; the writer changes only + * the members it is given, so choosing a channel never forgets the service + * and an installation never forgets the channel. */ export type UpdateConfig = Readonly<{ + channel: UpdateChannel; service: ServiceSpec; folder: string | null; }>; const configName = "config.json"; export const defaultUpdateConfig: UpdateConfig = Object.freeze({ + channel: defaultChannel, service: Object.freeze({ kind: "none" }), folder: null, }); @@ -26,12 +39,17 @@ export async function readUpdateConfig(base: string): Promise { const value = JSON.parse( await readFile(join(layout(base).update, configName), "utf8"), ) as Record | null; - const service = parseServiceSpec(value?.service); - if (value?.schemaVersion !== 1 || !service) return defaultUpdateConfig; + if (value?.schemaVersion !== 1) return defaultUpdateConfig; + const service = parseServiceSpec(value.service); return Object.freeze({ - service, + channel: isUpdateChannel(value.channel) + ? value.channel + : defaultUpdateConfig.channel, + // Service and Folder were decided together; one without the other + // would check a Folder for a supervisor that is not recorded. + service: service ?? defaultUpdateConfig.service, folder: - typeof value.folder === "string" && isAbsolute(value.folder) + service && typeof value.folder === "string" && isAbsolute(value.folder) ? value.folder : null, }); @@ -40,11 +58,18 @@ export async function readUpdateConfig(base: string): Promise { } } -export async function writeUpdateConfig( +/** Change the given members and keep the others as they can be read now. + * Returns the bytes that were replaced (undefined: there was no file), so a + * caller that undoes its work can put back exactly what was there. + */ +export async function changeUpdateConfig( base: string, - config: UpdateConfig, + change: Partial, write: DurableWriter, -): Promise { +): Promise { + const path = join(layout(base).update, configName); + const previous = await readFile(path).catch(() => undefined); + const config = { ...(await readUpdateConfig(base)), ...change }; await write( layout(base).update, configName, @@ -52,4 +77,5 @@ export async function writeUpdateConfig( `${JSON.stringify({ schemaVersion: 1, ...config }, null, 2)}\n`, ), ); + return previous; } diff --git a/src/update/defaults.ts b/src/update/defaults.ts new file mode 100644 index 00000000..997c376e --- /dev/null +++ b/src/update/defaults.ts @@ -0,0 +1,68 @@ +import type { UpdateChannel } from "./channel"; +import { verifiedRoot } from "./trust"; + +/** What an installed Lazurio knows without being told (docs/update.md + * "Identity in the binary"): where the signed repository is, which origins a + * download may touch, the default channel and the trust root. Every value is + * configuration with a default — a fork or a fixture passes its own through + * the explicit options of `lazurio update`. + */ +/** The published origin is a domain the project controls, a CNAME to GitHub + * Pages of this repository (docs/release-keys.md). It is compiled into every + * executable and therefore outlives any hosting choice: the static tree can + * move behind the name; an installed Lazurio cannot be told a new name. + */ +export const defaultRepositoryOrigin = "https://releases.lazurio.io"; +export const defaultMetadataBaseUrl = `${defaultRepositoryOrigin}/metadata/`; +export const defaultTargetBaseUrl = `${defaultRepositoryOrigin}/targets/`; +export const defaultChannel: UpdateChannel = "stable"; + +/** Origins an executable download may touch besides the repository's own. + * + * A release's executables are GitHub Release assets; the SIGNED location is + * `https://github.com///releases/download//`, which + * GitHub answers with `302` to a short-lived signed URL on its asset storage. + * GitHub documents that storage only as `*.githubusercontent.com` + * (`GET https://api.github.com/meta`, `domains.website`); the transport + * matches exact origins, never a wildcard, so the hosts are named: + * + * - `release-assets.githubusercontent.com` — where release downloads redirect + * today (observed 2026-09-19 for a public repository); + * - `objects.githubusercontent.com` — the host used before it, which GitHub's + * own firewall guidance still lists for release downloads. + * + * Any other redirect is refused by the transport (proven in + * `tests/update-publish-journey.test.ts`). The release workflow downloads + * every new asset through this same list before it publishes metadata, so a + * host GitHub introduces later stops a release, not installed Machines. + */ +export const defaultArtifactOrigins: readonly string[] = Object.freeze([ + "https://github.com", + "https://release-assets.githubusercontent.com", + "https://objects.githubusercontent.com", +]); + +/** `bun build --define` replaces this identifier with the text of + * `release/root.json` (see `identity.ts` for the mechanism). A run from + * source, and a build made before the key ceremony (docs/release-keys.md), + * has none: the product then holds NO trust root and `lazurio update` answers + * `trust-missing` unless the caller supplies `--bootstrap-root`. + */ +declare const LAZURIO_BUILD_TRUST_ROOT: string | undefined; + +export function embeddedTrustRoot(): Uint8Array | undefined { + return typeof LAZURIO_BUILD_TRUST_ROOT === "string" + ? new TextEncoder().encode(LAZURIO_BUILD_TRUST_ROOT) + : undefined; +} + +/** Build tooling passes the root through this function, so the writer and the + * reader of the define cannot drift apart. A root that does not verify under + * its own keys is a broken release input and is refused, never embedded. + */ +export function trustRootDefines(rootText: string | undefined): string[] { + if (rootText === undefined) return []; + if (verifiedRoot(rootText) === undefined) + throw new Error("release/root.json is not a self-signed TUF root"); + return ["--define", `LAZURIO_BUILD_TRUST_ROOT=${JSON.stringify(rootText)}`]; +} diff --git a/src/update/download.ts b/src/update/download.ts index a79dabdc..4d2034ef 100644 --- a/src/update/download.ts +++ b/src/update/download.ts @@ -3,7 +3,10 @@ import { constants } from "node:fs"; import { open, readFile, rm, statfs } from "node:fs/promises"; import { join } from "node:path"; import { DownloadHTTPError } from "tuf-js/dist/error"; -import type { RangeResponse } from "../distribution/transport"; +import { + OriginRefusedError, + type RangeResponse, +} from "../distribution/transport"; import type { AvailableSession, SignedArtifact } from "./check"; import { UpdateFailure } from "./errors"; import { @@ -95,15 +98,20 @@ export const defaultDownloadPolicy: DownloadPolicy = Object.freeze({ reserveBytes: 32 * 1024 * 1024, }); -/** The ONE mapping from a signed artifact target to its URL. The fixture and - * the first publisher serve TUF consistent-snapshot names - * (`artifacts//.lazurio`); mapping to release assets - * (docs/update.md "Publishing") changes only this function. +/** The ONE mapping from a signed artifact target to its URL. A release names + * the download location of its executable in the SIGNED target metadata + * (`custom.url`, a GitHub Release asset — docs/update.md "Publishing"); the + * bytes are verified against the signed length and digest wherever they came + * from, and the transport decides which origins may be contacted at all. A + * target without a location is served by the repository itself under its TUF + * consistent-snapshot name (`artifacts//.lazurio`): the + * loopback fixture, or a mirror that hosts everything in one tree. */ export function artifactUrl( targetBaseUrl: string, artifact: SignedArtifact, ): string { + if (artifact.url !== undefined) return artifact.url; const slash = artifact.path.lastIndexOf("/"); return `${targetBaseUrl}${artifact.path.slice(0, slash + 1)}${artifact.sha256}.${artifact.path.slice(slash + 1)}`; } @@ -207,6 +215,12 @@ export async function downloadArtifact(input: { } } catch (error) { if (error instanceof UpdateFailure) throw error; + // Policy, not weather: refused now is refused after every backoff. + if (error instanceof OriginRefusedError) + throw new UpdateFailure("origin-refused", { + resource: "artifact", + origin: error.origin, + }); lastStatus = error instanceof DownloadHTTPError ? error.statusCode : undefined; if (lastStatus === 416 && received > 0) await restart(); diff --git a/src/update/errors.ts b/src/update/errors.ts index dbb671ef..e7bd0e62 100644 --- a/src/update/errors.ts +++ b/src/update/errors.ts @@ -58,6 +58,9 @@ export const updateErrors = { * stands in front of it) serves metadata at or above the floors again, with * no repair on the Machine — but it is a security signal: watch the CODE. */ "metadata-rollback": { exit: 47, retryable: true }, + /** A signed location, or a redirect from it, names an origin this product + * does not contact. Repeating the action cannot change that. */ + "origin-refused": { exit: 48, retryable: false }, internal: { exit: 70, retryable: false }, } as const satisfies Record; diff --git a/src/update/install.ts b/src/update/install.ts index 84cc3341..dbf071ec 100644 --- a/src/update/install.ts +++ b/src/update/install.ts @@ -12,7 +12,7 @@ import { dirname, isAbsolute, join, resolve } from "node:path"; import { writtenSchemas } from "./activate"; import { type ServiceSpec, systemdUnitPattern } from "./activation-record"; import { type CheckInput, checkForUpdate } from "./check"; -import { writeUpdateConfig } from "./config"; +import { changeUpdateConfig } from "./config"; import { downloadSignedIdentity } from "./download"; import { writeDurableFile } from "./durable-file"; import { @@ -276,13 +276,23 @@ export async function performInstall( identity, run, }); - await writeUpdateConfig( + // Recorded together with the channel this installation verified + // against. An undone installation puts back exactly what was there — a + // channel chosen beforehand is not ours to forget. + const write = input.writeDurable ?? writeDurableFile; + const replaced = await changeUpdateConfig( base, - { service: spec, folder: service?.folder ?? null }, - input.writeDurable ?? writeDurableFile, + { + channel: input.channel, + service: spec, + folder: service?.folder ?? null, + }, + write, ); created.push(() => - rm(join(paths.update, "config.json"), { force: true }), + replaced === undefined + ? rm(join(paths.update, "config.json"), { force: true }) + : write(paths.update, "config.json", replaced), ); created.push(() => rm(paths.selector, { force: true })); await swapSelector(base, name); diff --git a/src/update/trust-fetcher.ts b/src/update/trust-fetcher.ts index db6e03ef..81748cfc 100644 --- a/src/update/trust-fetcher.ts +++ b/src/update/trust-fetcher.ts @@ -1,13 +1,18 @@ import type { Fetcher } from "tuf-js"; // Pinned-client compatibility: the Updater recognizes this class for root 404. import { DownloadHTTPError } from "tuf-js/dist/error"; -import { TransferTooLargeError } from "../distribution/transport"; +import { + OriginRefusedError, + TransferTooLargeError, +} from "../distribution/transport"; export type TransferFailure = Readonly<{ resource: string; httpStatus?: number; /** The response exceeded the limit the client asked for. */ tooLarge?: true; + /** The transport's origin policy refused the URL or a redirect from it. */ + originRefused?: true; }>; /** File name as kept in `trust/` (`timestamp.json`, `3.root.json`) and the @@ -88,6 +93,9 @@ export class TrustFetcher implements Fetcher { this.failure = Object.freeze({ resource: new URL(url).pathname.split("/").at(-1) ?? "", ...(status === undefined ? {} : { httpStatus: status }), + ...(error instanceof OriginRefusedError + ? { originRefused: true as const } + : {}), ...(error instanceof TransferTooLargeError ? { tooLarge: true as const } : {}), diff --git a/src/update/trust.ts b/src/update/trust.ts index 064fe22a..62d72ae8 100644 --- a/src/update/trust.ts +++ b/src/update/trust.ts @@ -177,7 +177,10 @@ function parseRoot(text: string): Metadata { return root; } -function verifiedRoot(text: string): Metadata | undefined { +/** Root metadata that verifies under its own keys; otherwise undefined. Also + * the build's gate for `release/root.json` (`defaults.ts`). + */ +export function verifiedRoot(text: string): Metadata | undefined { try { const parsed = parseRoot(text); parsed.verifyDelegate(MetadataKind.Root, parsed); diff --git a/tests/publish-repository.test.ts b/tests/publish-repository.test.ts new file mode 100644 index 00000000..1716473f --- /dev/null +++ b/tests/publish-repository.test.ts @@ -0,0 +1,989 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runReleaseKeys } from "../scripts/release-keys"; +import { exitValidityLow, runReleasePublish } from "../scripts/release-publish"; +import { PublishError } from "../src/publish/errors"; +import { + generateSigner, + privateKeyPem, + type Signer, +} from "../src/publish/keys"; +import { + buildSnapshot, + buildTargets, + buildTimestamp, + parseMetadata, + type TargetEntry, + verifiesUnder, +} from "../src/publish/metadata"; +import { rollbackViolations } from "../src/publish/monotonic"; +import { + addRelease, + loadRepository, + type PublishOptions, + promote, + type ReleaseArtifact, + refresh, + repositoryStatus, +} from "../src/publish/repository"; +import { initialRoot, nextRoot } from "../src/publish/root"; +import { applyPlan, directoryTree } from "../src/publish/tree"; +import { parseChannelDocument } from "../src/update/channel"; + +// Every key here is created in memory or in a temporary directory and dies +// with the test. Nothing in this file touches a network. +const day = 86_400_000; +const start = new Date("2026-09-19T10:00:00.000Z"); +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup(); +}); + +const sha256 = (bytes: Uint8Array) => + createHash("sha256").update(bytes).digest("hex"); + +async function temporary(prefix: string): Promise { + const directory = await realpath(await mkdtemp(join(tmpdir(), prefix))); + cleanups.push(() => rm(directory, { recursive: true, force: true })); + return directory; +} + +function artifact( + version: string, + target = "linux-x64", + url = `https://github.com/example/product/releases/download/v${version}/lazurio-${target}`, +): ReleaseArtifact { + const bytes = Buffer.from(`executable ${version} ${target}`); + return { + target, + sha256: sha256(bytes), + length: bytes.length, + identity: Buffer.from( + JSON.stringify({ + schemaVersion: 1, + version, + target, + sourceCommit: "c".repeat(40), + toolchain: "bun@0.0.0", + schemas: { preferences: [1], manifest: [1] }, + artifactSha256: sha256(bytes), + artifactBytes: bytes.length, + }), + ), + url, + }; +} + +async function repository() { + const directory = await temporary("publish-tree-"); + const tree = directoryTree(directory); + const signers: Record<"root" | "targets" | "snapshot" | "timestamp", Signer> = + { + root: generateSigner(), + targets: generateSigner(), + snapshot: generateSigner(), + timestamp: generateSigner(), + }; + const root = initialRoot({ + keys: { + root: [signers.root.key], + targets: [signers.targets.key], + snapshot: [signers.snapshot.key], + timestamp: [signers.timestamp.key], + }, + rootSigner: signers.root, + now: start, + }); + const options = ( + now = start, + overrides: Partial = {}, + ): PublishOptions => ({ signers, now, root, ...overrides }); + const release = async ( + version: string, + now = start, + targets = ["linux-x64"], + ) => { + const plan = await addRelease( + tree, + { + channel: "preview", + version, + notes: `Notes of ${version}`, + artifacts: targets.map((target) => artifact(version, target)), + }, + options(now), + ); + return { plan, written: await applyPlan(directory, plan) }; + }; + const files = async () => { + const found: Record = {}; + const walk = async (relative: string) => { + for (const entry of await readdir(join(directory, relative), { + withFileTypes: true, + })) { + const path = relative ? `${relative}/${entry.name}` : entry.name; + if (entry.isDirectory()) await walk(path); + else found[path] = sha256(await readFile(join(directory, path))); + } + }; + await walk(""); + return found; + }; + return { directory, tree, signers, root, options, release, files }; +} + +const refusal = async (action: Promise) => { + try { + await action; + } catch (error) { + if (error instanceof PublishError) return error.code; + throw error; + } + return "accepted"; +}; + +test("a release on an empty tree is a consistent snapshot: numbered roles, hash-addressed targets, the timestamp planned and written last", async () => { + const r = await repository(); + const { plan, written } = await r.release("1.1.0"); + expect(plan.result).toEqual({ + kind: "published", + channel: "preview", + version: "1.1.0", + sequence: 1, + }); + const release = artifact("1.1.0"); + const paths = plan.writes.map((write) => write.path); + expect(paths.at(-1)).toBe("metadata/timestamp.json"); + expect(paths.slice(-4)).toEqual([ + "metadata/1.root.json", + "metadata/1.targets.json", + "metadata/1.snapshot.json", + "metadata/timestamp.json", + ]); + expect(written).toEqual(paths); + expect(Object.keys(await r.files()).sort()).toEqual( + [ + "metadata/1.root.json", + "metadata/1.snapshot.json", + "metadata/1.targets.json", + "metadata/timestamp.json", + `targets/artifacts/${release.sha256}/${sha256(release.identity)}.identity.json`, + `targets/channels/${sha256( + await readFile( + join( + r.directory, + paths.find((path) => path.includes("preview")) as string, + ), + ), + )}.preview.json`, + `targets/releases/1.1.0/${sha256(Buffer.from("Notes of 1.1.0"))}.notes.md`, + ].sort(), + ); + // The executable stays a release asset: its location is part of the SIGNED + // target, next to its length and digest. + const loaded = await loadRepository(r.tree); + const signed = + loaded?.targets?.metadata.signed.targets[ + `artifacts/${release.sha256}/lazurio` + ]; + expect(signed?.length).toBe(release.length); + expect(signed?.hashes.sha256).toBe(release.sha256); + expect(signed?.custom).toEqual({ url: release.url }); + // Lifetimes of docs/release-cycle.md. + const status = repositoryStatus(loaded as NonNullable, start); + expect(status.roles.map((role) => [role.role, role.remainingDays])).toEqual([ + ["root", 365], + ["targets", 90], + ["snapshot", 30], + ["timestamp", 7], + ]); + expect(status.low).toEqual([]); +}); + +test("every object a previous generation referenced is retained byte for byte; only the timestamp is replaced", async () => { + const r = await repository(); + await r.release("1.1.0"); + const before = await r.files(); + const { written } = await r.release("1.2.0", new Date(start.getTime() + day)); + const after = await r.files(); + for (const [path, digest] of Object.entries(before)) + if (path !== "metadata/timestamp.json") expect(after[path]).toBe(digest); + expect(after["metadata/timestamp.json"]).not.toBe( + before["metadata/timestamp.json"], + ); + expect(written).toContain("metadata/2.targets.json"); + expect(written).toContain("metadata/2.snapshot.json"); + expect(written).not.toContain("metadata/1.root.json"); + // The older artifact is still a signed target: `stable` may still select it. + const targets = (await loadRepository(r.tree))?.targets?.metadata.signed + .targets; + expect(Object.keys(targets ?? {})).toContain( + `artifacts/${artifact("1.1.0").sha256}/lazurio`, + ); +}); + +test("nothing immutable is rewritten: a plan whose bytes differ is refused as a whole and changes nothing", async () => { + const r = await repository(); + await r.release("1.1.0"); + const before = await r.files(); + expect( + await refusal( + applyPlan(r.directory, { + writes: [ + { path: "targets/new/file.txt", bytes: Buffer.from("new") }, + { path: "metadata/1.targets.json", bytes: Buffer.from("other") }, + { path: "metadata/timestamp.json", bytes: Buffer.from("later") }, + ], + }), + ), + ).toBe("immutable-conflict"); + expect(await r.files()).toEqual(before); + // The timestamp must be last, and a path never leaves the tree. + for (const writes of [ + [ + { path: "metadata/timestamp.json", bytes: Buffer.from("t") }, + { path: "metadata/9.snapshot.json", bytes: Buffer.from("s") }, + ], + [{ path: "metadata/../../escape", bytes: Buffer.from("x") }], + [{ path: "elsewhere/file", bytes: Buffer.from("x") }], + ]) + expect(await refusal(applyPlan(r.directory, { writes }))).toBe( + "invalid-input", + ); + expect(await r.files()).toEqual(before); + // Identical bytes are not a conflict: the same plan can be applied again. + const again = await r.release("1.1.0"); + expect(again.plan.result.kind).toBe("unchanged"); + expect(again.written).toEqual([]); + // The same artifact under another signed location, other bytes under a + // version the channel already reached, an older version, a dropped target. + const publish = (artifacts: ReleaseArtifact[], version: string) => + refusal( + addRelease( + r.tree, + { channel: "preview", version, notes: "", artifacts }, + r.options(), + ), + ); + expect( + await publish( + [ + { + ...artifact("1.1.0"), + url: "https://github.com/example/other/lazurio", + }, + ], + "1.1.0", + ), + ).toBe("immutable-conflict"); + const other = Buffer.from("other bytes"); + expect( + await publish( + [ + { + ...artifact("1.1.0"), + sha256: sha256(other), + length: other.length, + identity: Buffer.from( + JSON.stringify({ + ...JSON.parse(artifact("1.1.0").identity.toString()), + artifactSha256: sha256(other), + artifactBytes: other.length, + }), + ), + }, + ], + "1.1.0", + ), + ).toBe("not-newer"); + expect(await publish([artifact("1.0.9")], "1.0.9")).toBe("not-newer"); + expect(await publish([artifact("1.3.0", "darwin-arm64")], "1.3.0")).toBe( + "target-dropped", + ); + // An identity that the client would refuse is never signed. + expect(await publish([artifact("1.3.0")], "1.4.0")).toBe("invalid-input"); + expect( + await publish( + [{ ...artifact("1.3.0"), url: "http://github.com/x" }], + "1.3.0", + ), + ).toBe("invalid-input"); + expect(await r.files()).toEqual(before); +}); + +test("promotion signs a stable document that names the same digests as preview, for exactly the reviewed version", async () => { + const r = await repository(); + await r.release("1.1.0", start, ["linux-x64", "darwin-arm64"]); + expect( + await refusal(promote(r.tree, { version: "1.0.0" }, r.options())), + ).toBe("version-mismatch"); + const before = await r.files(); + const plan = await promote(r.tree, { version: "1.1.0" }, r.options()); + const written = await applyPlan(r.directory, plan); + expect(plan.result).toMatchObject({ kind: "published", channel: "stable" }); + // Promotion adds one document and metadata; no artifact, identity or notes. + expect(written.filter((path) => path.startsWith("targets/"))).toEqual([ + expect.stringMatching(/^targets\/channels\/[a-f0-9]{64}\.stable\.json$/), + ]); + const document = async (channel: "stable" | "preview") => { + const name = (await readdir(join(r.directory, "targets/channels"))).find( + (entry) => entry.endsWith(`.${channel}.json`), + ) as string; + return parseChannelDocument( + await readFile(join(r.directory, "targets/channels", name)), + channel, + ); + }; + const stable = await document("stable"); + const preview = await document("preview"); + expect(stable.targets).toEqual(preview.targets); + expect(stable.version).toBe(preview.version); + expect(stable.sequence).toBe(1); + for (const [path, digest] of Object.entries(before)) + if (path !== "metadata/timestamp.json") + expect((await r.files())[path]).toBe(digest); + // Repeating it is a no-op; a pre-release is never promoted. + const again = await promote(r.tree, { version: "1.1.0" }, r.options()); + expect(again).toMatchObject({ writes: [], result: { kind: "unchanged" } }); + await r.release("1.2.0-rc.1", start, ["linux-x64", "darwin-arm64"]); + expect( + await refusal(promote(r.tree, { version: "1.2.0-rc.1" }, r.options())), + ).toBe("invalid-input"); +}); + +test("the signed minimum version of stable changes only through the explicit input of a promotion, only upwards, and never above the promoted version", async () => { + const r = await repository(); + const stable = async () => { + const name = (await readdir(join(r.directory, "targets/channels"))) + .filter((entry) => entry.endsWith(".stable.json")) + .map((entry) => join(r.directory, "targets/channels", entry)); + const documents = await Promise.all( + name.map(async (path) => + parseChannelDocument(await readFile(path), "stable"), + ), + ); + return documents.sort((a, b) => b.sequence - a.sequence)[0]; + }; + const run = async (version: string, minimumVersion?: string) => { + const plan = await promote( + r.tree, + { version, ...(minimumVersion ? { minimumVersion } : {}) }, + r.options(), + ); + await applyPlan(r.directory, plan); + return plan.result; + }; + // A floor set on preview is preview's: promotion never adopts it. + await applyPlan( + r.directory, + await addRelease( + r.tree, + { + channel: "preview", + version: "1.1.0", + notes: "", + artifacts: [artifact("1.1.0")], + minimumVersion: "1.0.5", + }, + r.options(), + ), + ); + expect(await run("1.1.0")).toMatchObject({ kind: "published", sequence: 1 }); + expect((await stable())?.minimumVersion).toBe("0.0.1"); + // Raised between releases: same version, same digests, a new document. + expect(await run("1.1.0", "1.0.0")).toMatchObject({ + kind: "published", + version: "1.1.0", + sequence: 2, + }); + expect(await stable()).toMatchObject({ + minimumVersion: "1.0.0", + targets: { "linux-x64": `artifacts/${artifact("1.1.0").sha256}/lazurio` }, + }); + expect(await run("1.1.0", "1.0.0")).toMatchObject({ kind: "unchanged" }); + // Never lowered, never out of reach, never malformed. + for (const floor of ["0.9.0", "1.1.1", "2", "1.1.0-rc.1x!"]) + expect( + await refusal( + promote( + r.tree, + { version: "1.1.0", minimumVersion: floor }, + r.options(), + ), + ), + ).toBe("invalid-input"); + // Carried over by a later promotion that does not name one. + await r.release("1.2.0"); + expect(await run("1.2.0")).toMatchObject({ sequence: 3 }); + expect((await stable())?.minimumVersion).toBe("1.0.0"); + // The command line passes it through. + await r.release("1.3.0"); + const keys = await temporary("publish-floor-keys-"); + for (const role of ["targets", "snapshot", "timestamp"] as const) + await writeFile( + join(keys, `${role}.private.pem`), + privateKeyPem(r.signers[role]), + { mode: 0o600 }, + ); + expect( + await runReleasePublish( + [ + "promote", + "--version", + "1.3.0", + "--minimum-version", + "1.2.0", + "--tree", + r.directory, + "--keys-dir", + keys, + ], + { env: {}, now: () => start }, + ), + ).toMatchObject({ code: 0 }); + expect(await stable()).toMatchObject({ + version: "1.3.0", + minimumVersion: "1.2.0", + sequence: 4, + }); +}); + +test("refresh re-signs only snapshot and timestamp, or only the timestamp; targets are untouched", async () => { + const r = await repository(); + await r.release("1.1.0"); + const later = new Date(start.getTime() + 3 * day); + const timestampOnly = await refresh(r.tree, ["timestamp"], r.options(later)); + expect(timestampOnly.result.rewritten).toEqual(["metadata/timestamp.json"]); + await applyPlan(r.directory, timestampOnly); + const both = await refresh(r.tree, ["snapshot"], r.options(later)); + expect(both.result.rewritten).toEqual([ + "metadata/2.snapshot.json", + "metadata/timestamp.json", + ]); + await applyPlan(r.directory, both); + const loaded = await loadRepository(r.tree); + expect(loaded?.targets?.version).toBe(1); + expect(loaded?.snapshot?.version).toBe(2); + expect(loaded?.timestamp?.version).toBe(3); + expect(loaded?.timestamp?.metadata.signed.expires).toBe( + "2026-09-29T10:00:00Z", + ); + // The refresh job holds no targets key and never needs one. + const { targets: _targets, ...freshness } = r.signers; + const withoutTargets = await refresh(r.tree, ["snapshot"], { + ...r.options(later), + signers: freshness, + }); + expect(withoutTargets.result.rewritten).toHaveLength(2); + expect( + await refusal( + refresh(r.tree, ["targets"], { + ...r.options(later), + signers: freshness, + }), + ), + ).toBe("key-missing"); + // A key the root does not name for the role is refused before signing. + expect( + await refusal( + refresh(r.tree, ["timestamp"], { + ...r.options(later), + signers: { ...r.signers, timestamp: generateSigner() }, + }), + ), + ).toBe("key-unauthorized"); +}); + +test("the tree is storage, not authority: metadata that does not verify under the published root is refused, never adopted and re-signed with the real keys", async () => { + const r = await repository(); + await r.release("1.1.0"); + await applyPlan( + r.directory, + await promote(r.tree, { version: "1.1.0" }, r.options()), + ); + const before = await r.files(); + const every = async () => [ + await refusal(refresh(r.tree, ["targets"], r.options())), + await refusal(refresh(r.tree, ["timestamp"], r.options())), + await refusal(promote(r.tree, { version: "1.1.0" }, r.options())), + await refusal( + addRelease( + r.tree, + { + channel: "preview", + version: "1.2.0", + notes: "", + artifacts: [artifact("1.2.0")], + }, + r.options(), + ), + ), + await refusal(loadRepository(r.tree)), + ]; + const restore = async () => { + for (const path of Object.keys(await r.files())) + if (!(path in before)) await rm(join(r.directory, path)); + await writeFile( + join(r.directory, "metadata/timestamp.json"), + timestampBefore, + ); + expect(await r.files()).toEqual(before); + }; + const timestampBefore = await readFile( + join(r.directory, "metadata/timestamp.json"), + ); + + // Somebody who can write to the branch — but holds no real key — publishes + // a complete generation of their own with one more artifact in it. The next + // approved run must not launder that entry through the real targets key. + const forger = { + targets: generateSigner(), + snapshot: generateSigner(), + timestamp: generateSigner(), + }; + const entries = new Map(); + const published = (await loadRepository(r.tree))?.targets?.metadata.signed + .targets; + for (const [path, file] of Object.entries(published ?? {})) + entries.set(path, { + length: file.length, + sha256: file.hashes.sha256 as string, + }); + entries.set(`artifacts/${"e".repeat(64)}/lazurio`, { + length: 1, + sha256: "e".repeat(64), + custom: { url: "https://github.com/attacker/x/releases/download/v9/x" }, + }); + const expires = "2030-01-01T00:00:00Z"; + const forge = async (signers: typeof forger) => { + const targets = buildTargets({ + version: 3, + expires, + targets: entries, + signer: signers.targets, + }); + const snapshot = buildSnapshot({ + version: 3, + expires, + targetsVersion: 3, + targetsBytes: targets, + signer: signers.snapshot, + }); + const timestamp = buildTimestamp({ + version: 3, + expires, + snapshotVersion: 3, + snapshotBytes: snapshot, + signer: signers.timestamp, + }); + await writeFile(join(r.directory, "metadata/3.targets.json"), targets); + await writeFile(join(r.directory, "metadata/3.snapshot.json"), snapshot); + await writeFile(join(r.directory, "metadata/timestamp.json"), timestamp); + }; + await forge(forger); + expect(new Set(await every())).toEqual(new Set(["repository-invalid"])); + await restore(); + // The holder of the REAL snapshot and timestamp keys (the daily job) still + // cannot add a target: targets metadata needs the targets key. + await forge({ + ...forger, + snapshot: r.signers.snapshot, + timestamp: r.signers.timestamp, + }); + expect(new Set(await every())).toEqual(new Set(["repository-invalid"])); + await restore(); + // … and pointing the timestamp back at an older, genuine state is noticed: + // numbered metadata above what the timestamp references is never ignored. + const first = parseMetadata( + "snapshot", + await readFile(join(r.directory, "metadata/1.snapshot.json")), + ); + expect(first.signed.version).toBe(1); + await writeFile( + join(r.directory, "metadata/timestamp.json"), + buildTimestamp({ + version: 9, + expires, + snapshotVersion: 1, + snapshotBytes: await readFile( + join(r.directory, "metadata/1.snapshot.json"), + ), + signer: r.signers.timestamp, + }), + ); + expect(new Set(await every())).toEqual(new Set(["repository-invalid"])); + await restore(); + // The leftover of an interrupted local run is the same case: a person + // removes it; the publisher never guesses. + await writeFile(join(r.directory, "metadata/3.snapshot.json"), "leftover"); + expect(new Set(await every())).toEqual(new Set(["repository-invalid"])); + await restore(); + expect(new Set(await every())).not.toContain("repository-invalid"); +}); + +test("status reports the remaining validity of every role and the command exits non-zero below a margin", async () => { + const r = await repository(); + await r.release("1.1.0"); + const loaded = (await loadRepository(r.tree)) as NonNullable< + Awaited> + >; + const at = (days: number) => new Date(start.getTime() + days * day); + expect(repositoryStatus(loaded, at(1)).low).toEqual([]); + expect(repositoryStatus(loaded, at(2.5)).low).toEqual(["timestamp"]); + expect(repositoryStatus(loaded, at(11)).low).toEqual([ + "snapshot", + "timestamp", + ]); + expect(repositoryStatus(loaded, at(61)).low).toEqual([ + "targets", + "snapshot", + "timestamp", + ]); + expect(repositoryStatus(loaded, at(306)).low).toContain("root"); + expect(repositoryStatus(loaded, at(2.5), { timestamp: 1 }).low).toEqual([]); + + const run = (args: string[], days: number) => + runReleasePublish([...args, "--tree", r.directory], { + env: {}, + now: () => at(days), + }); + expect((await run(["status"], 1)).code).toBe(0); + const low = await run(["status"], 61); + expect(low.code).toBe(exitValidityLow); + expect(low.stdout).toContain("| targets | 1 |"); + expect(low.stdout).toContain("Below margin: targets, snapshot, timestamp"); + expect(JSON.parse((await run(["status", "--json"], 61)).stdout).low).toEqual([ + "targets", + "snapshot", + "timestamp", + ]); + // `--when-low` without any key is fine while nothing is low … + expect(await run(["refresh", "--when-low"], 1)).toMatchObject({ + code: 0, + stdout: "nothing to renew", + }); + // … and names the missing key, never anything else, when it has to sign. + expect(await run(["refresh", "--when-low"], 2.5)).toMatchObject({ + code: 1, + stderr: "Refused: key-missing (timestamp)", + }); +}); + +test("a rotated root is signed by the outgoing and the incoming key, continues the chain, and forces re-signing of exactly the roles whose key changed", async () => { + const r = await repository(); + await r.release("1.1.0"); + const incoming = generateSigner(); + const targets = generateSigner(); + const keys = { + root: [incoming.key], + targets: [targets.key], + snapshot: [r.signers.snapshot.key], + timestamp: [r.signers.timestamp.key], + }; + // Without the outgoing key no installation would accept it. + expect(() => + nextRoot({ current: r.root, keys, rootSigners: [incoming], now: start }), + ).toThrow(PublishError); + expect(() => + nextRoot({ + current: r.root, + keys, + rootSigners: [r.signers.root], + now: start, + }), + ).toThrow(PublishError); + const second = nextRoot({ + current: r.root, + keys, + rootSigners: [r.signers.root, incoming], + now: start, + }); + const first = parseMetadata("root", r.root); + const next = parseMetadata("root", second); + expect(next.signed.version).toBe(2); + expect(verifiesUnder(first, "root", next)).toBe(true); + expect(verifiesUnder(next, "root", next)).toBe(true); + + // The old targets key is no longer authorized; the new one re-signs. + expect( + await refusal(refresh(r.tree, [], r.options(start, { root: second }))), + ).toBe("key-unauthorized"); + const plan = await refresh(r.tree, [], { + ...r.options(start, { root: second }), + signers: { ...r.signers, targets }, + }); + expect(plan.result.rewritten).toEqual([ + "metadata/2.root.json", + "metadata/2.targets.json", + "metadata/2.snapshot.json", + "metadata/timestamp.json", + ]); + await applyPlan(r.directory, plan); + const loaded = await loadRepository(r.tree); + expect(loaded?.roots.map((root) => root.version)).toEqual([1, 2]); + // A root that skips a version, or other bytes under a published version. + const third = nextRoot({ + current: second, + keys, + rootSigners: [incoming], + now: start, + }); + const fourth = nextRoot({ + current: third, + keys, + rootSigners: [incoming], + now: start, + }); + const resign = (root: Buffer) => + refusal( + refresh(r.tree, [], { + ...r.options(start, { root }), + signers: { ...r.signers, targets }, + }), + ); + expect(await resign(fourth)).toBe("root-chain"); + expect(await resign(second)).toBe("accepted"); + // A release tag from before the rotation still carries root 1: a no-op. + expect(await resign(r.root)).toBe("accepted"); + const forged = initialRoot({ + keys, + rootSigner: incoming, + now: new Date(start.getTime() + day), + }); + expect(await resign(forged)).toBe("root-chain"); + // An empty tree without a root is refused, never invented. + const empty = await temporary("publish-empty-"); + expect( + await refusal( + addRelease( + directoryTree(empty), + { + channel: "preview", + version: "1.0.0", + notes: "", + artifacts: [artifact("1.0.0")], + }, + { signers: r.signers, now: start }, + ), + ), + ).toBe("root-missing"); +}); + +test("the key tool writes a 0600 private key outside any repository, builds and rotates the root, and never prints key material", async () => { + const keys = await temporary("release-keys-"); + const outputs: string[] = []; + const run = async (args: string[]) => { + const output = await runReleaseKeys(args, () => start); + outputs.push(output.stdout, output.stderr); + return output; + }; + for (const role of ["root", "targets", "snapshot", "timestamp"]) + expect((await run(["generate", "--role", role, "--out", keys])).code).toBe( + 0, + ); + expect((await stat(join(keys, "root.private.pem"))).mode & 0o777).toBe(0o600); + // Never overwritten, never inside a repository, never an unknown role. + expect((await run(["generate", "--role", "root", "--out", keys])).code).toBe( + 1, + ); + expect( + await run([ + "generate", + "--role", + "root", + "--out", + join(import.meta.dir, "generated-keys"), + ]), + ).toMatchObject({ + code: 1, + stderr: + "Refused: invalid-input: keys are never generated inside a repository", + }); + expect((await run(["generate", "--role", "admin", "--out", keys])).code).toBe( + 1, + ); + + const release = join(keys, "release"); + await mkdir(release); + const rootFile = join(release, "root.json"); + const publics = ["targets", "snapshot", "timestamp"].flatMap((role) => [ + `--${role}`, + join(keys, `${role}.public.json`), + ]); + expect( + ( + await run([ + "init-root", + "--root-key", + join(keys, "root.private.pem"), + ...publics, + "--out", + rootFile, + ]) + ).code, + ).toBe(0); + const first = await readFile(rootFile); + const parsed = parseMetadata("root", first); + expect(parsed.signed.version).toBe(1); + expect(parsed.signed.expires).toBe("2027-09-19T10:00:00Z"); + expect(parsed.signed.consistentSnapshot).toBe(true); + for (const role of ["root", "targets", "snapshot", "timestamp"]) + expect(parsed.signed.roles[role]?.threshold).toBe(1); + // A world-readable private key is refused. + const loose = join(keys, "loose.pem"); + await writeFile(loose, await readFile(join(keys, "root.private.pem")), { + mode: 0o644, + }); + expect( + ( + await run([ + "init-root", + "--root-key", + loose, + ...publics, + "--out", + join(release, "other.json"), + ]) + ).code, + ).toBe(1); + + // Rotate the root key in place; the published chain accepts the result. + const next = await temporary("release-keys-next-"); + await run(["generate", "--role", "root", "--out", next]); + expect( + ( + await run([ + "rotate-root", + "--current", + rootFile, + "--root-key", + join(keys, "root.private.pem"), + "--new-root-key", + join(next, "root.private.pem"), + "--out", + rootFile, + ]) + ).code, + ).toBe(0); + const second = parseMetadata("root", await readFile(rootFile)); + expect(second.signed.version).toBe(2); + expect(verifiesUnder(parsed, "root", second)).toBe(true); + expect(second.signed.roles.targets?.keyIDs).toEqual( + parsed.signed.roles.targets?.keyIDs, + ); + + const everything = outputs.join("\n"); + expect(everything).not.toContain("PRIVATE KEY"); + for (const role of ["root", "targets", "snapshot", "timestamp"]) { + const pem = await readFile(join(keys, `${role}.private.pem`), "utf8"); + const body = pem.split("\n")[1] as string; + expect(body.length).toBeGreaterThan(40); + expect(everything).not.toContain(body); + } +}); + +test("the tree branch is append-only: one commit per deployment, a rewritten or removed file stops the push", async () => { + const r = await repository(); + const work = await temporary("publish-branch-"); + const remote = join(work, "remote.git"); + const git = (args: string[]) => + Bun.spawnSync(["git", ...args], { stdout: "pipe", stderr: "pipe" }); + expect(git(["init", "--bare", "--quiet", remote]).exitCode).toBe(0); + const script = join(import.meta.dir, "../scripts/update-tree.sh"); + const tool = (args: string[], from = remote, domain = "releases.example") => { + const result = Bun.spawnSync(["bash", script, ...args], { + env: { + PATH: process.env.PATH ?? "", + UPDATE_TREE_REMOTE: from, + UPDATE_TREE_CNAME: domain, + }, + stdout: "pipe", + stderr: "pipe", + }); + return { code: result.exitCode, stdout: result.stdout.toString() }; + }; + const commits = () => + git(["-C", remote, "rev-list", "--count", "gh-pages"]) + .stdout.toString() + .trim(); + // A remote that cannot be read is an error, never "nothing published yet": + // that reading would let the daily refresh go green while metadata lapses. + expect( + tool(["checkout", join(work, "unreachable")], join(work, "absent.git")) + .code, + ).toBe(1); + // First run: no branch yet. The publisher writes into the checkout. + const first = join(work, "first"); + expect(tool(["checkout", first]).code).toBe(0); + const plan = await addRelease( + directoryTree(first), + { + channel: "preview", + version: "1.1.0", + notes: "", + artifacts: [artifact("1.1.0")], + }, + r.options(), + ); + await applyPlan(first, plan); + expect(tool(["push", first, "Release 1.1.0 to preview"]).code).toBe(0); + expect(commits()).toBe("1"); + // The custom domain of the Pages site travels with the tree; a tree that is + // published under another domain than the product is built for is refused. + expect(await readFile(join(first, "CNAME"), "utf8")).toBe( + "releases.example\n", + ); + expect(tool(["push", first, "other"], remote, "elsewhere.example").code).toBe( + 1, + ); + expect((await runReleasePublish(["pages-domain"], { env: {} })).stdout).toBe( + "releases.lazurio.io", + ); + // Nothing changed: nothing is pushed. + expect(tool(["push", first, "again"]).stdout).toContain("Nothing changed"); + expect(commits()).toBe("1"); + // A later run starts from what is published and appends to it. + const second = join(work, "second"); + expect(tool(["checkout", second]).code).toBe(0); + expect((await loadRepository(directoryTree(second)))?.targets?.version).toBe( + 1, + ); + await applyPlan( + second, + await refresh(directoryTree(second), ["snapshot"], r.options()), + ); + // What the workflows hold the tree against before a deployment: the + // published commit, whatever this run has written into the checkout since. + const published = join(work, "published"); + expect(tool(["baseline", second, published]).code).toBe(0); + expect( + (await loadRepository(directoryTree(published)))?.snapshot?.version, + ).toBe(1); + expect((await loadRepository(directoryTree(second)))?.snapshot?.version).toBe( + 2, + ); + expect( + await rollbackViolations(directoryTree(published), directoryTree(second)), + ).toEqual([]); + expect(tool(["push", second, "Refresh update metadata"]).code).toBe(0); + expect(commits()).toBe("2"); + // Whatever produced it, a changed or deleted published file never leaves. + await writeFile(join(second, "metadata/1.targets.json"), "rewritten"); + expect(tool(["push", second, "rewrite"]).code).toBe(1); + git(["-C", second, "reset", "--quiet", "--hard"]); + await rm(join(second, "metadata/1.snapshot.json")); + expect(tool(["push", second, "remove"]).code).toBe(1); + expect(commits()).toBe("2"); +}); diff --git a/tests/update-cli.test.ts b/tests/update-cli.test.ts index 893ed15f..3b52ec85 100644 --- a/tests/update-cli.test.ts +++ b/tests/update-cli.test.ts @@ -24,6 +24,10 @@ async function scenario() { const environment = { identity: { version: "1.0.0", commit: "b".repeat(40), target: "linux-x64" }, clock: () => new Date("2026-09-19T10:00:00.000Z"), + // A command without `--base` resolves the per-user base from HOME: it must + // land inside this test's directory, never in the developer's own base. + platform: "linux", + env: { HOME: root }, }; const origins = [ "--metadata-url", @@ -118,8 +122,19 @@ test("every refusal prints its stable code and exits with that code's distinct s code: 20, stderr: "Update failed: network-unavailable", }); + // Without options the compiled-in repository is asked — but a run from + // source has no compiled-in root, so it fails closed before any transfer. + expect(await run(["--check"])).toMatchObject({ + code: 25, + stderr: "Update failed: trust-missing", + }); + const repository = origins.slice(0, 4); for (const args of [ - ["--check"], + // Another repository is named as a whole, or not at all. + ["--check", ...origins.filter((_, index) => index > 1)], + ["--check", "--base", base, "--artifact-origin", "https://example.org"], + ["--check", ...origins, "--artifact-origin", "https://example.org/path"], + ["--check", ...repository, "--base", base, "--channel", "pilot"], ["--check", ...origins, "--channel", "stable"], [ "--check", diff --git a/tests/update-errors.test.ts b/tests/update-errors.test.ts new file mode 100644 index 00000000..2c101baf --- /dev/null +++ b/tests/update-errors.test.ts @@ -0,0 +1,54 @@ +import { expect, test } from "bun:test"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + exitUpdateAvailable, + exitUpToDate, + updateErrors, +} from "../src/update/errors"; + +/** `src/update/errors.ts` is the ONE place where error codes and their exit + * statuses live. The update work is developed on several stacked branches, and + * two of them once claimed the same number: a merge resolves text, not + * meaning. The runtime object cannot show a duplicated NAME — the later + * property silently wins — so the table is also read as source text. + */ +test("the error table has no duplicate code name and no duplicate, reserved or retired exit status", async () => { + const source = await readFile( + join(import.meta.dir, "../src/update/errors.ts"), + "utf8", + ); + const table = source.slice( + source.indexOf("export const updateErrors = {"), + source.indexOf("} as const satisfies"), + ); + const entries = [ + ...table.matchAll( + /^\s*(?:"([a-z-]+)"|([a-z]+)):\s*\{\s*exit:\s*(\d+),\s*retryable:\s*(?:true|false)\s*\}/gm, + ), + ].map((match) => ({ + name: (match[1] ?? match[2]) as string, + exit: Number(match[3]), + })); + // Every entry of the source was recognized: nothing escapes this test by + // being written differently. + expect(entries.length).toBe(Object.keys(updateErrors).length); + expect(table.match(/exit:/g)?.length).toBe(entries.length); + + const duplicates = (values: readonly T[]) => + values.filter((value, index) => values.indexOf(value) !== index); + expect(duplicates(entries.map((entry) => entry.name))).toEqual([]); + expect(duplicates(entries.map((entry) => entry.exit))).toEqual([]); + const runtime: Record = updateErrors; + for (const { name, exit } of entries) expect(runtime[name]?.exit).toBe(exit); + + // Statuses with another meaning, and numbers of codes that were removed: + // a retired number is never given to a new code. + const retired = [23, 26, 31]; + for (const comment of retired) expect(table).toContain(`${comment} was`); + const forbidden = [0, 1, exitUpToDate, exitUpdateAvailable, ...retired]; + expect(entries.filter((entry) => forbidden.includes(entry.exit))).toEqual([]); + expect(entries.every((entry) => entry.exit >= 2 && entry.exit <= 125)).toBe( + true, + ); +}); diff --git a/tests/update-identity.test.ts b/tests/update-identity.test.ts index be364eb6..8e791b84 100644 --- a/tests/update-identity.test.ts +++ b/tests/update-identity.test.ts @@ -107,9 +107,9 @@ test.skipIf(process.platform === "win32")( folder: null, }); expect(run(["update", "--base", join(directory, "base")])).toEqual({ - code: 2, + code: 34, stdout: "", - stderr: "Update failed: invalid-request", + stderr: "Update failed: not-installed", }); } finally { await rm(directory, { recursive: true }); diff --git a/tests/update-install.test.ts b/tests/update-install.test.ts index bdb01416..c351008d 100644 --- a/tests/update-install.test.ts +++ b/tests/update-install.test.ts @@ -167,6 +167,7 @@ for (const umask of [0o002, 0o077]) "observed.json", ]); expect(await readUpdateConfig(s.base)).toEqual({ + channel: "stable", service: { kind: "none" }, folder: null, }); @@ -329,12 +330,14 @@ test("install --service writes, enables and starts its own unit, records the ser ]); // What `lazurio update` reads instead of flags. expect(await readUpdateConfig(s.base)).toEqual({ + channel: "stable", service: { kind: "systemd-user", unit: unit.unit }, folder: unit.folder, }); // Damaged or absent, the record means "no service, no Folder": never an error. await writeFile(join(s.base, "update", "config.json"), "{ not json"); expect(await readUpdateConfig(s.base)).toEqual({ + channel: "stable", service: { kind: "none" }, folder: null, }); diff --git a/tests/update-publish-floors.test.ts b/tests/update-publish-floors.test.ts new file mode 100644 index 00000000..e4981450 --- /dev/null +++ b/tests/update-publish-floors.test.ts @@ -0,0 +1,410 @@ +import { afterEach, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { cp, mkdir, mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { exitRollback, runReleasePublish } from "../scripts/release-publish"; +import { DistributionTransport } from "../src/distribution/transport"; +import { generateSigner, type Signer } from "../src/publish/keys"; +import { floorsOf, rollbackViolations } from "../src/publish/monotonic"; +import { + addRelease, + type PublishOptions, + promote, + type RefreshRole, + refresh, +} from "../src/publish/repository"; +import { initialRoot, nextRoot } from "../src/publish/root"; +import { applyPlan, directoryTree } from "../src/publish/tree"; +import { checkForUpdate } from "../src/update/check"; +import { + type FloorVector, + parseFloors, + violationOf, +} from "../src/update/floors"; + +/** The publisher against the client's FLOOR VECTOR (docs/update.md "The floor + * vector"). One Machine keeps its install base — and therefore its + * `trust/floors.json` — through a normal publishing history and must never + * see `metadata-rollback`; shown an older generation of the same repository + * it must see exactly that. All keys are ephemeral and in memory. + */ +const target = "linux-x64"; +const clock = () => new Date(); +const cleanups: (() => Promise)[] = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0)) await cleanup(); +}); + +const sha256 = (bytes: Uint8Array) => + createHash("sha256").update(bytes).digest("hex"); + +function artifact(version: string) { + const bytes = Buffer.from(`executable ${version}`); + return { + target, + sha256: sha256(bytes), + length: bytes.length, + identity: Buffer.from( + JSON.stringify({ + schemaVersion: 1, + version, + target, + sourceCommit: "c".repeat(40), + toolchain: "bun@0.0.0", + schemas: { preferences: [1], manifest: [1] }, + artifactSha256: sha256(bytes), + artifactBytes: bytes.length, + }), + ), + url: `https://github.com/example/product/releases/download/v${version}/lazurio-${target}`, + }; +} + +async function scenario() { + const work = await realpath(await mkdtemp(join(tmpdir(), "publish-floors-"))); + const live = join(work, "live"); + await mkdir(live); + /** The directory the static server answers from: the live tree, or a copy + * of an older generation of it. */ + let serving = live; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const path = decodeURIComponent(new URL(request.url).pathname); + if (path.split("/").includes("..")) + return new Response(null, { status: 400 }); + const file = Bun.file(join(serving, path)); + return (await file.exists()) + ? new Response(file) + : new Response("not found", { status: 404 }); + }, + }); + cleanups.push(async () => { + await server.stop(true); + await rm(work, { recursive: true, force: true }); + }); + const signers: Record<"root" | "targets" | "snapshot" | "timestamp", Signer> = + { + root: generateSigner(), + targets: generateSigner(), + snapshot: generateSigner(), + timestamp: generateSigner(), + }; + const keys = () => ({ + root: [signers.root.key], + targets: [signers.targets.key], + snapshot: [signers.snapshot.key], + timestamp: [signers.timestamp.key], + }); + const firstRoot = initialRoot({ + keys: keys(), + rootSigner: signers.root, + now: new Date(), + }); + let root = firstRoot; + const options = (): PublishOptions => ({ signers, now: new Date(), root }); + const tree = directoryTree(live); + let generations = 0; + const publisher = { + async release(version: string) { + await applyPlan( + live, + await addRelease( + tree, + { + channel: "preview", + version, + notes: `Notes of ${version}`, + artifacts: [artifact(version)], + }, + options(), + ), + ); + }, + async promote(version: string) { + await applyPlan(live, await promote(tree, { version }, options())); + }, + /** In the live tree, or — with the same genuine keys — in another one. */ + async refresh(roles: RefreshRole[], directory = live) { + await applyPlan( + directory, + await refresh(directoryTree(directory), roles, options()), + ); + }, + /** New root key AND new targets key: every role below is re-signed. */ + async rotate() { + const outgoing = signers.root; + signers.root = generateSigner(); + signers.targets = generateSigner(); + root = nextRoot({ + current: root, + keys: keys(), + rootSigners: [outgoing, signers.root], + now: new Date(), + }); + await applyPlan(live, await refresh(tree, [], options())); + }, + /** A complete copy of what is published now. */ + async keep() { + const copy = join(work, `generation-${++generations}`); + await cp(live, copy, { recursive: true }); + return copy; + }, + }; + const base = join(work, "machine", "base"); + const transport = new DistributionTransport( + [server.url.origin], + 10_000, + new AbortController().signal, + true, + ); + const check = (channel: "preview" | "stable" = "preview") => + checkForUpdate({ + base, + metadataBaseUrl: `${server.url}metadata/`, + targetBaseUrl: `${server.url}targets/`, + channel, + identity: { version: "1.0.0", commit: "a".repeat(40), target }, + // The compiled-in root of this Machine's executable: root 1, forever. + bootstrapRoot: firstRoot, + transport, + clock, + lockTimeoutMs: 10_000, + }); + const floors = async (): Promise => + parseFloors(await readFile(join(base, "trust", "floors.json"), "utf8")); + return { + work, + live, + tree, + publisher, + check, + floors, + serve(directory: string | undefined) { + serving = directory ?? live; + }, + }; +} + +test("a normal publishing history never trips the client's floor vector, and every re-signing advances it", async () => { + const s = await scenario(); + const seen: FloorVector[] = []; + /** After each publishing step: the Machine checks, the answer is never a + * rollback, and its vector only ever moves forward. */ + const step = async ( + expected: Record, + channel?: "preview" | "stable", + ) => { + const result = await s.check(channel); + expect(result).not.toMatchObject({ code: "metadata-rollback" }); + expect(result).toMatchObject(expected); + const vector = await s.floors(); + // What the publisher's own gate computes from the tree is what the + // Machine recorded from the network: one definition of the facts. + expect(await floorsOf(s.tree)).toEqual(vector); + seen.push(vector); + return vector; + }; + + await s.publisher.release("1.1.0"); + const a = await step({ kind: "available", version: "1.1.0" }); + expect([a.timestamp?.version, a.snapshot?.version]).toEqual([1, 1]); + expect(a.roles["targets.json"]?.version).toBe(1); + + await s.publisher.release("1.2.0"); + const b = await step({ kind: "available", version: "1.2.0" }); + expect(b.roles["targets.json"]?.version).toBe(2); + + await s.publisher.promote("1.2.0"); + await step({ kind: "available", version: "1.2.0" }, "stable"); + + // Refreshes: a NEW version every time, never the same version with other + // signed content (which the client refuses as equivocation). + const before = await s.floors(); + await s.publisher.refresh(["timestamp"]); + const t = await step({ kind: "available", version: "1.2.0" }); + expect(t.timestamp?.version).toBe((before.timestamp?.version ?? 0) + 1); + expect(t.timestamp?.signedSha256).not.toBe(before.timestamp?.signedSha256); + expect(t.snapshot).toEqual(before.snapshot); + expect(t.timestamp?.snapshot).toEqual(before.timestamp?.snapshot); + + await s.publisher.refresh(["snapshot"]); + const n = await step({ kind: "available", version: "1.2.0" }); + expect(n.snapshot?.version).toBe((t.snapshot?.version ?? 0) + 1); + expect(n.timestamp?.version).toBe((t.timestamp?.version ?? 0) + 1); + expect(n.timestamp?.snapshot.version).toBe(n.snapshot?.version); + // Targets were not touched: the entry is the same reference, not a new one. + expect(n.snapshot?.meta["targets.json"]).toEqual( + t.snapshot?.meta["targets.json"], + ); + expect(n.roles["targets.json"]).toEqual(t.roles["targets.json"]); + + await s.publisher.refresh(["targets"]); + const g = await step({ kind: "available", version: "1.2.0" }); + expect(g.roles["targets.json"]?.version).toBe( + (n.roles["targets.json"]?.version ?? 0) + 1, + ); + + // Root rotation with a new targets key: the Machine still holds root 1 as + // its compiled-in root and walks the chain; re-signed roles are NEW versions. + await s.publisher.rotate(); + const r = await step({ kind: "available", version: "1.2.0" }); + expect(r.root.version).toBe(2); + expect(Object.keys(r.root.retained).sort()).toEqual(["1", "2"]); + expect(r.roles["targets.json"]?.version).toBe( + (g.roles["targets.json"]?.version ?? 0) + 1, + ); + + await s.publisher.release("1.3.0"); + const c = await step({ kind: "available", version: "1.3.0" }); + await step({ kind: "available", version: "1.2.0" }, "stable"); + + // The whole history, pairwise: `snapshot.meta` never dropped or lowered an + // entry, no reference went down, no version repeated with other content. + for (let index = 1; index < seen.length; index++) { + const earlier = seen[index - 1] as FloorVector; + const later = seen[index] as FloorVector; + expect(Object.keys(later.snapshot?.meta ?? {})).toEqual( + expect.arrayContaining(Object.keys(earlier.snapshot?.meta ?? {})), + ); + for (const [name, entry] of Object.entries(earlier.snapshot?.meta ?? {})) + expect(later.snapshot?.meta[name]?.version).toBeGreaterThanOrEqual( + entry.version, + ); + expect(later.timestamp?.version).toBeGreaterThanOrEqual( + earlier.timestamp?.version ?? 0, + ); + expect(later.timestamp?.snapshot.version).toBeGreaterThanOrEqual( + earlier.timestamp?.snapshot.version ?? 0, + ); + } + // Release notes and every other target live in targets metadata, which the + // vector binds by version and signed content: nothing of A or B was lost. + const targets = JSON.parse( + await readFile( + join(s.live, `metadata/${c.roles["targets.json"]?.version}.targets.json`), + "utf8", + ), + ).signed.targets as Record; + for (const version of ["1.1.0", "1.2.0", "1.3.0"]) { + expect(Object.keys(targets)).toContain(`releases/${version}/notes.md`); + expect(Object.keys(targets)).toContain( + `artifacts/${artifact(version).sha256}/lazurio`, + ); + } +}, 60_000); + +test("an older generation of the same repository IS refused by the real client as metadata-rollback, and the publisher's gate refuses to publish one", async () => { + const s = await scenario(); + await s.publisher.release("1.1.0"); + const generationA = await s.publisher.keep(); + await s.publisher.release("1.2.0"); + const generationB = await s.publisher.keep(); + await s.publisher.refresh(["snapshot"]); + const generationC = await s.publisher.keep(); + expect(await s.check()).toMatchObject({ + kind: "available", + version: "1.2.0", + }); + const vector = await s.floors(); + + // Every byte of generation A is genuine and correctly signed. It is only old. + s.serve(generationA); + const rewound = await s.check(); + expect(rewound).toMatchObject({ kind: "error", code: "metadata-rollback" }); + // Nothing moved: the Machine neither forgot what it saw nor adopted A. + expect(await s.floors()).toEqual(vector); + // A Machine that never saw B has no reason to refuse A: retention. + // (`tests/update-publish-journey.test.ts` proves that with a download.) + + // Back on the live tree the same Machine simply continues. + s.serve(undefined); + expect(await s.check()).toMatchObject({ + kind: "available", + version: "1.2.0", + }); + + // The publisher's gate, with the CLIENT's comparison function: the live + // tree continues every kept generation; an older one continues no newer one. + for (const generation of [generationA, generationB, generationC]) + expect(await rollbackViolations(directoryTree(generation), s.tree)).toEqual( + [], + ); + const backwards = await rollbackViolations( + directoryTree(generationC), + directoryTree(generationA), + ); + expect( + backwards.map((violation) => [violation.role, violation.rule]), + ).toEqual([ + ["timestamp", "version"], + ["snapshot", "version"], + ["targets.json", "version"], + ]); + // It IS the client's function: the first violation is what `violationOf` + // answers, for the vector this Machine recorded, to a timestamp of version 1. + expect(backwards[0]).toEqual( + violationOf(vector, { + kind: "timestamp", + version: 1, + signedSha256: "0".repeat(64), + snapshot: { version: 1 }, + }), + ); + + // A branch reset to generation A on which the publisher then built "the + // next version": genuine keys, fresh signatures — and version 2 of snapshot + // and timestamp, which every Machine has already seen with OTHER content. + // That is equivocation, and no version check would notice it. + const reset = join(s.work, "reset"); + await cp(generationA, reset, { recursive: true }); + await s.publisher.refresh(["snapshot"], reset); + expect( + ( + await rollbackViolations(directoryTree(generationB), directoryTree(reset)) + ).map((violation) => [violation.role, violation.rule]), + ).toEqual([ + ["timestamp", "content"], + ["snapshot", "content"], + ["targets.json", "version"], + ]); + s.serve(reset); + expect(await s.check()).toMatchObject({ + kind: "error", + code: "metadata-rollback", + }); + s.serve(undefined); + + // The command the workflows run before every deployment. + const status = (against: string, tree: string, extra: string[] = []) => + runReleasePublish( + ["status", "--tree", tree, "--against", against, ...extra], + { env: {} }, + ); + expect((await status(generationA, generationC)).code).toBe(0); + expect((await status(generationC, generationA)).code).toBe(exitRollback); + const gate = await status(generationB, reset, ["--rollback-only"]); + expect(gate.code).toBe(exitRollback); + expect(gate.stdout).toContain( + "ROLLBACK: installed Machines would refuse this tree as `metadata-rollback`", + ); + expect(gate.stdout).toContain( + "- timestamp: rule content, published 2, offered 2", + ); + expect(gate.stdout).not.toContain("| role |"); + expect( + (await status(generationA, generationC, ["--rollback-only"])).stdout, + ).toBe("Monotonic against the published tree: ok"); + expect( + JSON.parse((await status(generationC, generationA, ["--json"])).stdout) + .rollback, + ).toHaveLength(3); + // A first deployment has nothing published to contradict. + const nothing = join(s.work, "nothing"); + await mkdir(nothing); + expect((await status(nothing, generationA, ["--rollback-only"])).code).toBe( + 0, + ); +}, 60_000); diff --git a/tests/update-publish-journey.test.ts b/tests/update-publish-journey.test.ts new file mode 100644 index 00000000..2146ee44 --- /dev/null +++ b/tests/update-publish-journey.test.ts @@ -0,0 +1,712 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { + copyFile, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { runReleaseKeys } from "../scripts/release-keys"; +import { runReleasePublish } from "../scripts/release-publish"; +import { DistributionTransport } from "../src/distribution/transport"; +import type { UpdateChannel } from "../src/update/channel"; +import { checkForUpdate } from "../src/update/check"; +import { runInstallCommand, runUpdateCommand } from "../src/update/cli"; +import { changeUpdateConfig, readUpdateConfig } from "../src/update/config"; +import { trustRootDefines } from "../src/update/defaults"; +import { writeDurableFile } from "../src/update/durable-file"; +import { identityDefines } from "../src/update/identity"; +import { readObserved } from "../src/update/observed"; +import { performUpdate } from "../src/update/update"; +import { + commit, + identityOf, + installVersion, + type ProductBinary, + productBinary, + target, +} from "./helpers/update-product"; + +/** The publisher and the REAL client against each other. The tree the + * publisher writes is served as static files on loopback — what GitHub Pages + * does — and release assets are served by a second loopback origin that + * answers with a redirect to a third one — what GitHub Releases does. Keys + * are generated by the key tool into a temporary directory and die with it. + */ +const clock = () => new Date(); +let work: string; +let tree: string; +let keys: string; +let rootFile: string; +let pages: ReturnType; +let releases: ReturnType; +let storage: ReturnType; +/** Serve these bytes as `timestamp.json`: a CDN that still holds an old one. */ +let cachedTimestamp: Buffer | undefined; +const assets = new Map(); +/** Every request any of the three origins received: ` `. */ +const requests: string[] = []; + +beforeAll(async () => { + work = await realpath(await mkdtemp(join(tmpdir(), "publish-journey-"))); + tree = join(work, "tree"); + keys = join(work, "keys"); + rootFile = join(work, "release-root.json"); + await mkdir(tree); + for (const role of ["root", "targets", "snapshot", "timestamp"]) + expect( + (await runReleaseKeys(["generate", "--role", role, "--out", keys])).code, + ).toBe(0); + expect( + ( + await runReleaseKeys([ + "init-root", + "--root-key", + join(keys, "root.private.pem"), + "--targets", + join(keys, "targets.public.json"), + "--snapshot", + join(keys, "snapshot.public.json"), + "--timestamp", + join(keys, "timestamp.public.json"), + "--out", + rootFile, + ]) + ).code, + ).toBe(0); + pages = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const path = decodeURIComponent(new URL(request.url).pathname); + requests.push(`pages ${path}`); + if (path.includes("..")) return new Response("refused", { status: 400 }); + if (path === "/metadata/timestamp.json" && cachedTimestamp) + return new Response(new Uint8Array(cachedTimestamp)); + const file = Bun.file(join(tree, path)); + return (await file.exists()) + ? new Response(file) + : new Response("not found", { status: 404 }); + }, + }); + storage = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + requests.push(`storage ${new URL(request.url).pathname}`); + const bytes = assets.get(new URL(request.url).pathname); + return bytes + ? new Response(new Uint8Array(bytes)) + : new Response("not found", { status: 404 }); + }, + }); + releases = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const path = new URL(request.url).pathname; + requests.push(`releases ${path}`); + return assets.has(path) + ? new Response(null, { + status: 302, + headers: { location: `${storage.url.origin}${path}?signature=x` }, + }) + : new Response("not found", { status: 404 }); + }, + }); +}); + +afterAll(async () => { + await pages?.stop(true); + await releases?.stop(true); + await storage?.stop(true); + await rm(work, { recursive: true, force: true }); +}); + +const publish = (args: string[]) => + runReleasePublish( + [...args, "--tree", tree, "--root", rootFile, "--keys-dir", keys], + { env: {} }, + ); + +/** What the release workflow hands to `add-release`: the build's executable + * and its `identity.json` per target, named like the release assets. + */ +async function release( + binary: ProductBinary, + hosting: "tree" | "release-asset", +) { + const directory = join(work, `assets-${binary.version}`); + await mkdir(directory); + await copyFile(binary.path, join(directory, `lazurio-${target}`)); + await writeFile( + join(directory, `lazurio-${target}.identity.json`), + JSON.stringify({ + kind: "unsigned-development-candidate", + identity: identityOf(binary), + }), + ); + const notes = join(directory, "notes.md"); + await writeFile(notes, `## What's changed in ${binary.version}\n`); + const base = `/example/product/releases/download/v${binary.version}/`; + if (hosting === "release-asset") + assets.set(`${base}lazurio-${target}`, binary.bytes); + return publish([ + "add-release", + "--channel", + "preview", + "--version", + binary.version, + "--notes-file", + notes, + "--assets-dir", + directory, + // This journey builds for the Machine it runs on only. + "--targets", + target, + ...(hosting === "release-asset" + ? [ + "--asset-url-base", + `${releases.url.origin}${base}`, + "--loopback-fixture", + ] + : []), + ]); +} + +async function machine(channel: UpdateChannel, origins?: string[]) { + const base = join( + await realpath(await mkdtemp(join(work, "machine-"))), + "base", + ); + const installed = await productBinary("1.0.0"); + await installVersion(base, installed); + const transport = new DistributionTransport( + origins ?? [pages.url.origin, releases.url.origin, storage.url.origin], + 60_000, + new AbortController().signal, + true, + ); + const input = async () => ({ + base, + metadataBaseUrl: `${pages.url}metadata/`, + targetBaseUrl: `${pages.url}targets/`, + channel, + identity: { version: "1.0.0", commit, target }, + // The compiled-in root is a SUPPLIED root: offered on every run, it seeds + // empty trust and is never a conflict afterwards. + bootstrapRoot: await readFile(rootFile), + transport, + clock, + lockTimeoutMs: 10_000, + }); + return { + base, + check: async () => checkForUpdate(await input()), + download: async () => { + return performUpdate({ + ...(await input()), + service: { kind: "none" }, + openRange: (url, offset, signal) => + transport.openRange(url, offset, signal), + downloadOnly: true, + }); + }, + trustedRootVersion: async () => + JSON.parse(await readFile(join(base, "trust", "root.json"), "utf8")) + .signed.version as number, + }; +} + +test("publish A, the client downloads it; publish B as a release asset behind a signed URL; promote; rotate the root — one repository, the real client", async () => { + const a = await productBinary("1.1.0"); + const b = await productBinary("1.2.0"); + + // A: everything inside the static tree. + expect(await release(a, "tree")).toMatchObject({ code: 0 }); + const preview = await machine("preview"); + expect(await preview.check()).toMatchObject({ + kind: "available", + version: "1.1.0", + artifactSha256: a.sha256, + sequence: 1, + }); + expect(await preview.download()).toEqual({ + kind: "ready", + version: "1.1.0", + staged: a.name, + }); + const staged = (base: string, binary: ProductBinary) => + readFile(join(base, "versions", binary.name, "lazurio")); + expect((await staged(preview.base, a)).equals(a.bytes)).toBe(true); + // Nothing was promoted: `stable` has no document yet. + const stable = await machine("stable"); + expect(await stable.check()).toMatchObject({ + kind: "error", + code: "channel-invalid", + context: { reason: "absent" }, + }); + const timestampOfA = await readFile(join(tree, "metadata/timestamp.json")); + + // B: the executable is a release asset; only its signed URL is in the tree. + expect(await release(b, "release-asset")).toMatchObject({ code: 0 }); + expect( + (await readdir(join(tree, "targets/artifacts", b.sha256))).sort(), + ).toEqual([expect.stringMatching(/\.identity\.json$/)]); + // What the release workflow does before it publishes: download every + // selected artifact through the client's transport. A release host that + // redirects to an origin outside the list stops the release. + expect( + await publish([ + "verify", + "--loopback-fixture", + "--artifact-origin", + releases.url.origin, + ]), + ).toMatchObject({ + code: 1, + stderr: expect.stringContaining("Refused: unreachable"), + }); + expect( + await publish([ + "verify", + "--loopback-fixture", + "--artifact-origin", + releases.url.origin, + "--artifact-origin", + storage.url.origin, + ]), + ).toMatchObject({ code: 0 }); + expect(await preview.check()).toMatchObject({ + kind: "available", + version: "1.2.0", + artifactSha256: b.sha256, + sequence: 2, + }); + + // The redirect target of a release asset must be a listed origin. A + // transport that knows the repository and the release host, but not where + // the release host redirects to, refuses — and leaves nothing behind. + const narrow = await machine("preview", [ + pages.url.origin, + releases.url.origin, + ]); + const refusedAt = performance.now(); + expect(await narrow.download()).toEqual({ + kind: "error", + code: "origin-refused", + context: { resource: "artifact", origin: storage.url.origin }, + }); + // Policy, not weather: no retry, no backoff. + expect(performance.now() - refusedAt).toBeLessThan(5_000); + expect(await readdir(join(narrow.base, "versions"))).toEqual([ + (await productBinary("1.0.0")).name, + ]); + expect(await preview.download()).toEqual({ + kind: "ready", + version: "1.2.0", + staged: b.name, + }); + expect((await staged(preview.base, b)).equals(b.bytes)).toBe(true); + + // Promotion: `stable` now offers exactly the bytes `preview` offered. + expect(await publish(["promote", "--version", "1.1.0"])).toMatchObject({ + code: 1, + stderr: expect.stringContaining("version-mismatch"), + }); + // The approver also raises the signed minimum version: explicit, never + // automatic. A Machine below it learns that from its next check. + expect( + await publish([ + "promote", + "--version", + "1.2.0", + "--minimum-version", + "1.1.0", + ]), + ).toMatchObject({ code: 0 }); + expect(await stable.check()).toMatchObject({ + kind: "available", + version: "1.2.0", + artifactSha256: b.sha256, + sequence: 1, + }); + expect(await readObserved(stable.base, clock())).toMatchObject({ + belowMinimumVersion: true, + available: { version: "1.2.0", minimumVersion: "1.1.0" }, + }); + expect(await readObserved(preview.base, clock())).toMatchObject({ + belowMinimumVersion: false, + }); + expect(await stable.download()).toMatchObject({ + kind: "ready", + staged: b.name, + }); + + // Retention: somebody still served the timestamp of A — a CDN cache — gets a + // COMPLETE older repository: snapshot, targets, document, identity, bytes. + const deployed = (seconds: string) => + publish([ + "await-deployment", + "--metadata-url", + `${pages.url}metadata/`, + "--timeout-seconds", + seconds, + "--loopback-fixture", + ]); + expect(await deployed("5")).toMatchObject({ code: 0 }); + cachedTimestamp = timestampOfA; + try { + // The workflow waits for what clients see, not for what it pushed. + expect(await deployed("1")).toMatchObject({ + code: 1, + stderr: expect.stringContaining("Refused: unreachable"), + }); + const behind = await machine("preview"); + expect(await behind.check()).toMatchObject({ + kind: "available", + version: "1.1.0", + }); + expect(await behind.download()).toMatchObject({ + kind: "ready", + staged: a.name, + }); + } finally { + cachedTimestamp = undefined; + } + + // Root rotation: a new root key AND a new targets key, made offline by the + // key tool, installed by a refresh. A Machine that trusts root 1 walks the + // chain with its own trust code and keeps updating. + const next = join(work, "keys-next"); + for (const role of ["root", "targets"]) + await runReleaseKeys(["generate", "--role", role, "--out", next]); + expect(await stable.trustedRootVersion()).toBe(1); + expect( + ( + await runReleaseKeys([ + "rotate-root", + "--current", + rootFile, + "--root-key", + join(keys, "root.private.pem"), + "--new-root-key", + join(next, "root.private.pem"), + "--targets", + join(next, "targets.public.json"), + "--out", + join(work, "root-2.json"), + ]) + ).code, + ).toBe(0); + // The old targets key can no longer sign; nothing is written. + const rotate = (keysDirectory: string) => + runReleasePublish( + [ + "refresh", + "--tree", + tree, + "--root", + join(work, "root-2.json"), + "--keys-dir", + keysDirectory, + ], + { env: {} }, + ); + expect(await rotate(keys)).toMatchObject({ + code: 1, + stderr: "Refused: key-unauthorized (targets)", + }); + await copyFile( + join(keys, "snapshot.private.pem"), + join(next, "snapshot.private.pem"), + ); + await copyFile( + join(keys, "timestamp.private.pem"), + join(next, "timestamp.private.pem"), + ); + expect(await rotate(next)).toMatchObject({ code: 0 }); + expect(await stable.check()).toMatchObject({ + kind: "available", + version: "1.2.0", + }); + expect(await stable.trustedRootVersion()).toBe(2); + // A Machine installed before the rotation, whose executable embeds root 1. + expect(await (await machine("stable")).check()).toMatchObject({ + kind: "available", + version: "1.2.0", + }); +}, 120_000); + +test("switching the channel is explicit, persisted in update/config.json, tolerant of damage, and never offers a lower version", async () => { + const base = join( + await realpath(await mkdtemp(join(work, "channel-"))), + "base", + ); + const run = (args: string[], version: string) => + runUpdateCommand([...args, "--base", base], { + identity: { version, commit, target }, + clock, + }); + const channel = async () => (await readUpdateConfig(base)).channel; + expect(await channel()).toBe("stable"); + expect(await run(["channel"], "1.0.0")).toMatchObject({ + code: 0, + stdout: "Channel: stable", + }); + expect(await run(["channel", "nightly"], "1.0.0")).toMatchObject({ code: 2 }); + expect(await run(["channel", "preview", "--json"], "1.0.0")).toEqual({ + code: 0, + stdout: '{"channel":"preview"}', + stderr: "", + }); + expect( + JSON.parse(await readFile(join(base, "update", "config.json"), "utf8")), + ).toEqual({ + schemaVersion: 1, + channel: "preview", + service: { kind: "none" }, + folder: null, + }); + await writeFile(join(base, "update", "config.json"), "{ damaged"); + expect(await channel()).toBe("stable"); + await writeFile( + join(base, "update", "config.json"), + JSON.stringify({ schemaVersion: 2, channel: "preview" }), + ); + expect(await channel()).toBe("stable"); + // One file, one writer: what `lazurio install` recorded about the service + // and the Folder survives a channel switch, member by member. + const recorded = { + service: { kind: "systemd-user", unit: "lazurio-launchpad.service" }, + folder: "/home/u/Lazurio", + } as const; + await changeUpdateConfig(base, recorded, writeDurableFile); + await run(["channel", "preview"], "1.0.0"); + expect(await readUpdateConfig(base)).toEqual({ + channel: "preview", + ...recorded, + }); + await writeFile( + join(base, "update", "config.json"), + JSON.stringify({ schemaVersion: 1, channel: "weekly", ...recorded }), + ); + expect(await readUpdateConfig(base)).toEqual({ + channel: "stable", + ...recorded, + }); + await changeUpdateConfig( + base, + { channel: "preview", service: { kind: "none" }, folder: null }, + writeDurableFile, + ); + + // The repository of the journey above: preview and stable offer 1.2.0. A + // Machine that already runs something newer is never offered a downgrade, + // whichever channel it switches to. + const origins = [ + "--metadata-url", + `${pages.url}metadata/`, + "--target-url", + `${pages.url}targets/`, + "--loopback-fixture", + "--check", + "--json", + ]; + const bootstrap = join(work, "bootstrap-root.json"); + await writeFile(bootstrap, await readFile(rootFile), { mode: 0o600 }); + const first = await run( + [...origins, "--bootstrap-root", bootstrap], + "1.3.0-rc.1", + ); + expect(first.code).toBe(0); + expect(JSON.parse(first.stdout)).toMatchObject({ + kind: "up-to-date", + version: "1.3.0-rc.1", + channelVersion: "1.2.0", + }); + expect(await run(["channel", "stable"], "1.3.0-rc.1")).toMatchObject({ + code: 0, + }); + const switched = await run(origins, "1.3.0-rc.1"); + expect(switched.code).toBe(0); + expect(JSON.parse(switched.stdout).kind).toBe("up-to-date"); + // … and an older Machine on the configured channel is offered the update. + expect((await run(origins, "1.0.0")).code).toBe(10); +}); + +test("lazurio install proves itself against the published repository with the compiled-in root, downloads nothing but signed metadata and its identity, and keeps a channel chosen before", async () => { + // B was published as a RELEASE ASSET: the tree holds its signed target entry + // (with `custom.url`) and its identity, never its bytes. Install locates + // both by the same target paths the updater uses. + const b = await productBinary("1.2.0"); + const home = await realpath(await mkdtemp(join(work, "install-"))); + const base = join(home, "base"); + const environment = { + identity: { version: "1.2.0", commit, target }, + embeddedRoot: await readFile(rootFile), + executable: b.path, + clock, + env: { HOME: home }, + }; + const repository = [ + "--metadata-url", + `${pages.url}metadata/`, + "--target-url", + `${pages.url}targets/`, + "--loopback-fixture", + "--base", + base, + "--json", + ]; + // Without a compiled-in root and without --bootstrap-root: nothing happens. + const { embeddedRoot: _none, ...rootless } = environment; + const refused = await runInstallCommand(repository, rootless); + expect(refused.code).toBe(25); + expect(await readdir(home)).toEqual([]); + + await runUpdateCommand(["channel", "preview", "--base", base], environment); + const from = requests.length; + const installed = await runInstallCommand(repository, environment); + expect(JSON.parse(installed.stdout)).toMatchObject({ + kind: "installed", + version: "1.2.0", + name: b.name, + available: null, + }); + expect(installed.code).toBe(0); + const touched = requests.slice(from); + expect(touched.every((request) => request.startsWith("pages "))).toBe(true); + expect( + touched.filter((request) => request.startsWith("pages /targets/")), + ).toEqual([ + expect.stringMatching( + /^pages \/targets\/channels\/[a-f0-9]{64}\.preview\.json$/, + ), + expect.stringMatching( + new RegExp( + `^pages /targets/artifacts/${b.sha256}/[a-f0-9]{64}\\.identity\\.json$`, + ), + ), + ]); + // The channel chosen before the installation was recorded with it. + expect(await readUpdateConfig(base)).toEqual({ + channel: "preview", + service: { kind: "none" }, + folder: null, + }); + // An executable the repository never signed is not installed. + const stranger = await productBinary("1.2.1"); + const other = join(home, "other-base"); + const unsigned = await runInstallCommand( + repository.map((value) => (value === base ? other : value)), + { + ...environment, + identity: { version: "1.2.1", commit, target }, + executable: stranger.path, + }, + ); + expect(JSON.parse(unsigned.stdout)).toMatchObject({ + kind: "error", + code: "unverified-executable", + }); + expect(await readdir(home)).toEqual(["base"]); +}, 120_000); + +test("a product built without release/root.json embeds no root and fails closed; built with one it needs no bootstrap root", async () => { + const build = async (name: string, rootText: string | undefined) => { + const output = join(work, name); + const child = Bun.spawn( + [ + process.execPath, + "build", + resolve(import.meta.dir, "../src/cli.ts"), + "--compile", + "--no-compile-autoload-dotenv", + "--no-compile-autoload-bunfig", + ...identityDefines({ version: "1.0.0", commit, target }), + ...trustRootDefines(rootText), + "--outfile", + output, + ], + { stdout: "ignore", stderr: "pipe" }, + ); + if ((await child.exited) !== 0) + throw new Error(await new Response(child.stderr).text()); + // Asynchronous: the repository is served by this very process. + return async (args: string[]) => { + const result = Bun.spawn([output, ...args], { + env: { HOME: work }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr] = await Promise.all([ + new Response(result.stdout).text(), + new Response(result.stderr).text(), + ]); + return { + code: await result.exited, + stdout: stdout.trim(), + stderr: stderr.trim(), + }; + }; + }; + const repository = [ + "--metadata-url", + `${pages.url}metadata/`, + "--target-url", + `${pages.url}targets/`, + "--loopback-fixture", + ]; + const withoutRoot = await build("lazurio-without-root", undefined); + const closed = join(work, "closed-base"); + // Against the compiled-in repository AND against a named one: no root, no + // update. Nothing is fetched and NOTHING is created. + for (const args of [[], repository]) + expect( + await withoutRoot(["update", "--check", "--base", closed, ...args]), + ).toEqual({ + code: 25, + stdout: "", + stderr: "Update failed: trust-missing", + }); + expect(await readdir(work)).not.toContain("closed-base"); + // The same holds for the installation itself. + expect( + await withoutRoot(["install", "--base", closed, ...repository]), + ).toEqual({ + code: 25, + stdout: "", + stderr: "Install failed: trust-missing", + }); + expect(await readdir(work)).not.toContain("closed-base"); + // The root of the journey's repository is version 1; its chain leads to 2. + const withRoot = await build( + "lazurio-with-root", + await readFile(rootFile, "utf8"), + ); + const open = join(work, "open-base"); + for (let attempt = 0; attempt < 2; attempt++) { + // On the second run the compiled-in root meets durable trust that has + // moved past it (root 2): ignored, never a conflict. + const checked = await withRoot([ + "update", + "--check", + "--json", + "--base", + open, + ...repository, + ]); + expect(checked.code).toBe(10); + expect(JSON.parse(checked.stdout)).toMatchObject({ + kind: "available", + version: "1.2.0", + }); + } + expect(() => trustRootDefines('{"signed":{}}')).toThrow(); +}, 120_000);