From 996194d49c575cd4cd7aa1416e6b24dd6806f906 Mon Sep 17 00:00:00 2001 From: Evan Downing <2077950+evandowning@users.noreply.github.com> Date: Thu, 30 Jul 2026 08:45:42 -0400 Subject: [PATCH 1/3] Verify release attestation offline against published bundle asset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit install.sh ran `gh attestation verify --repo`, which fetches the Sigstore bundle from the GitHub attestations API. `gh` always attaches its stored credential, so a token without an SSO session for the trailofbits org 403s on public data — external users cannot install even though the artifact is public and its SHA256SUMS check already passed. Publish the provenance bundle as a release asset (attestations.jsonl) and verify it offline with `gh attestation verify --bundle`, which makes no API call and needs no credential. The --repo identity constraint and the artifact digest match are still enforced, so transporting the already-signed bundle over an unauthenticated download does not weaken the guarantee. - release.yml: give the attest step an id, normalize its bundle output to one-per-line with `jq -c`, and publish attestations.jsonl with the release. - install.sh: download the bundle asset and pass --bundle; releases before the asset existed fail closed with a clear message. - README.md / RELEASING.md: document the offline path and the credential-free manual recipe; strip credentials in the release smoke test. src/update.rs has the identical bug (coop update shells out to the same API-fetching verify) and needs the same fix in a follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 10 +++++++++- README.md | 19 ++++++++++++++++--- RELEASING.md | 11 +++++++++-- install.sh | 9 ++++++++- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b38b30f..368bc16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -100,10 +100,18 @@ jobs: run: sha256sum coop-*.tar.gz > SHA256SUMS - name: Attest build provenance + id: attest uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 with: subject-path: "coop-*.tar.gz" + # Normalize to one bundle per line so the published asset has a stable + # shape regardless of whether the action emits a single bundle or several. + - name: Publish attestation bundle + env: + BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} + run: jq -c . "$BUNDLE_PATH" > attestations.jsonl + - name: Extract release notes from CHANGELOG env: TAG: ${{ github.ref_name }} @@ -122,4 +130,4 @@ jobs: env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} - run: gh release create "$TAG" --title "$TAG" --notes-file RELEASE_NOTES.md coop-*.tar.gz SHA256SUMS + run: gh release create "$TAG" --title "$TAG" --notes-file RELEASE_NOTES.md coop-*.tar.gz SHA256SUMS attestations.jsonl diff --git a/README.md b/README.md index a61815d..1a6ea3e 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,12 @@ The attestation proves the artifact was built from this repository by the tagged release workflow. Both `install.sh` and `coop update` run this verification automatically -when the [GitHub CLI](https://cli.github.com/) is installed. Without `gh`, -they fall back to checksum verification against the release's `SHA256SUMS` -and print a note explaining what was and wasn't verified. +when the [GitHub CLI](https://cli.github.com/) is installed. `install.sh` +verifies offline against the `attestations.jsonl` bundle published with the +release, so that verification step makes no GitHub API call and needs no +authentication, whereas `coop update` verifies through the GitHub attestations +API. Without `gh`, both fall back to checksum verification against the +release's `SHA256SUMS` and print a note explaining what was and wasn't verified. To verify a downloaded tarball manually: @@ -141,6 +144,16 @@ To verify a downloaded tarball manually: gh attestation verify coop--.tar.gz --repo trailofbits/coop ``` +The API call above requires a GitHub credential. To verify offline against the +published bundle instead — the workaround if your token has no SSO session for +the `trailofbits` org — download `attestations.jsonl` from the release and pass +`--bundle`: + +```sh +gh attestation verify coop--.tar.gz --repo trailofbits/coop \ + --bundle attestations.jsonl +``` + ## Requirements Tested on macOS arm64 (Apple Silicon) and Linux x86_64. Linux arm64 builds are available but untested. diff --git a/RELEASING.md b/RELEASING.md index 091e967..eb26b03 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -92,11 +92,18 @@ CI can't run the full VM integration suite or the extra-toolchain checks This triggers `release.yml`. 9. **Verify the published release.** On the GitHub release page confirm: - - three `coop-vX.Y.Z-.tar.gz` artifacts plus `SHA256SUMS`, + - three `coop-vX.Y.Z-.tar.gz` artifacts plus `SHA256SUMS` and + `attestations.jsonl`, - the build-provenance attestation is attached, - the notes match the `## vX.Y.Z` CHANGELOG section. - Then smoke-test the install path (`install.sh`) against the new tag. + Then smoke-test the install path with credentials stripped, so the offline + bundle verification is exercised as an external user sees it: + + ```bash + env -u GH_TOKEN -u GITHUB_TOKEN GH_CONFIG_DIR="$(mktemp -d)" \ + INSTALL_DIR="$(mktemp -d)" bash install.sh + ``` ## If the tag run fails diff --git a/install.sh b/install.sh index 56046db..99fbf22 100755 --- a/install.sh +++ b/install.sh @@ -10,6 +10,7 @@ set -euo pipefail REPO="trailofbits/coop" BINARY="coop" +BUNDLE="attestations.jsonl" INSTALL_DIR="${INSTALL_DIR:-${HOME}/.local/bin}" # --- helpers ---------------------------------------------------------------- @@ -114,7 +115,13 @@ verify_attestation() { local file="$1" if has gh; then info "Verifying attestation..." - gh attestation verify "$file" --repo "$REPO" \ + # Verify offline against the bundle published with the release. `gh + # attestation verify` without --bundle queries the GitHub API, and gh + # always attaches its token, so a token lacking an SSO session for the + # org 403s on public data. The --repo identity check is still enforced. + download_asset "$BUNDLE" "${TMPDIR}/${BUNDLE}" \ + || die "Could not download ${BUNDLE} for ${VERSION} — releases before the bundle was published cannot be verified offline; install a newer version" + gh attestation verify "$file" --repo "$REPO" --bundle "${TMPDIR}/${BUNDLE}" \ || die "Attestation verification failed for $(basename "$file") — refusing to install" else info "Note: \`gh\` not installed — skipped cryptographic attestation verification." From 9534c097b3808733535dc07685eeb0d23df4d55b Mon Sep 17 00:00:00 2001 From: Evan Downing <2077950+evandowning@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:13:17 -0400 Subject: [PATCH 2/3] Verify coop update offline too, and keep old releases installable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #421 found two problems with the installer change. install.sh failed closed when a release published no attestations.jsonl. Since README serves the installer from main, merging would have broken `curl … | bash` for every user until the next release: latest is v0.5.4, which has no bundle asset, and the error told users to "install a newer version" that does not exist. It also broke `VERSION=v0.5.x` pinned installs permanently. Fall back to the API path in that case instead — exactly what every release did before — so the bundle is a strict improvement and never a regression. A bundle that fails to verify still refuses the install; only a missing bundle falls back. src/update.rs had the same API-only verification, so `coop update` still 403'd for the users this fixes. It now downloads the release's attestations.jsonl and passes --bundle, with the same fallback. The bundle is skipped when gh is absent or the API base is overridden, so a fallible download cannot fail an update whose attestation step is a no-op anyway. Also: install.sh's no-gh hint and update.rs's equivalent advised the credential-requiring recipe; both now show --bundle. README leads with the offline recipe, RELEASING.md pins VERSION in the smoke test, and docs/trust-model.md records that --bundle changes transport, not the guarantee — --repo still pins signer identity and the bundle is signed. Verified against v0.5.4 with GH_CONFIG_DIR empty and GH_TOKEN/GITHUB_TOKEN unset: offline bundle path installs (exit 0), corrupt bundle refuses (exit 1), missing bundle falls back to the API, and a multi-subject bundle — which is what the release actually produces — verifies. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 35 ++++++----- RELEASING.md | 9 ++- docs/trust-model.md | 15 +++-- install.sh | 40 ++++++++---- src/update.rs | 144 +++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 198 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 1a6ea3e..1011b3c 100644 --- a/README.md +++ b/README.md @@ -130,28 +130,31 @@ attestation via [`actions/attest-build-provenance`](https://github.com/actions/a The attestation proves the artifact was built from this repository by the tagged release workflow. -Both `install.sh` and `coop update` run this verification automatically -when the [GitHub CLI](https://cli.github.com/) is installed. `install.sh` -verifies offline against the `attestations.jsonl` bundle published with the -release, so that verification step makes no GitHub API call and needs no -authentication, whereas `coop update` verifies through the GitHub attestations -API. Without `gh`, both fall back to checksum verification against the -release's `SHA256SUMS` and print a note explaining what was and wasn't verified. - -To verify a downloaded tarball manually: +Both `install.sh` and `coop update` run this verification automatically when +the [GitHub CLI](https://cli.github.com/) is installed. Both verify offline +against the `attestations.jsonl` bundle published with the release, so +verification makes no GitHub API call and needs no authentication. Releases +published before that asset existed are verified through the GitHub +attestations API instead, which does require a credential authorized for the +`trailofbits` org. Without `gh`, both fall back to checksum verification +against the release's `SHA256SUMS` and print a note explaining what was and +wasn't verified. + +To verify a downloaded tarball manually, download `attestations.jsonl` from the +same release and pass `--bundle`: ```sh -gh attestation verify coop--.tar.gz --repo trailofbits/coop +gh attestation verify coop--.tar.gz --repo trailofbits/coop \ + --bundle attestations.jsonl ``` -The API call above requires a GitHub credential. To verify offline against the -published bundle instead — the workaround if your token has no SSO session for -the `trailofbits` org — download `attestations.jsonl` from the release and pass -`--bundle`: +That needs no GitHub credential. Dropping `--bundle` makes `gh` fetch the +attestation from the API instead, which requires one — and fails with +`HTTP 403: Resource protected by organization SAML enforcement` if your token +carries no SSO session for the org: ```sh -gh attestation verify coop--.tar.gz --repo trailofbits/coop \ - --bundle attestations.jsonl +gh attestation verify coop--.tar.gz --repo trailofbits/coop ``` ## Requirements diff --git a/RELEASING.md b/RELEASING.md index eb26b03..99174dd 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -98,13 +98,18 @@ CI can't run the full VM integration suite or the extra-toolchain checks - the notes match the `## vX.Y.Z` CHANGELOG section. Then smoke-test the install path with credentials stripped, so the offline - bundle verification is exercised as an external user sees it: + bundle verification is exercised as an external user sees it. Pin `VERSION` + to the tag you just pushed rather than relying on "latest": ```bash env -u GH_TOKEN -u GITHUB_TOKEN GH_CONFIG_DIR="$(mktemp -d)" \ - INSTALL_DIR="$(mktemp -d)" bash install.sh + VERSION=vX.Y.Z INSTALL_DIR="$(mktemp -d)" bash install.sh ``` + The run must print `Verifying attestation...` without a "No + `attestations.jsonl` published" line — that line means the asset is missing + and verification silently fell back to the credential-requiring API path. + ## If the tag run fails **Immutable releases are enabled org-wide, so a version cannot be recovered.** diff --git a/docs/trust-model.md b/docs/trust-model.md index e8c1da6..5a45e21 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -133,11 +133,16 @@ Self-update (`update.rs`) must preserve, in order: 3. **Mandatory checksum.** The `SHA256SUMS` asset must be present (install is refused otherwise) and every downloaded tarball is verified against it (`verify_sha256`, constant-size `Sha256Hash` compare). -4. **Best-effort attestation.** `gh attestation verify --repo trailofbits/coop` - (Sigstore provenance). Skipped with a logged note if `gh` is absent, and - skipped entirely when `COOP_UPDATE_API_BASE_URL` is overridden (test mode). - So provenance is *not* guaranteed on hosts without `gh` — checksum is the - floor. +4. **Best-effort attestation.** `gh attestation verify --repo trailofbits/coop + --bundle attestations.jsonl` (Sigstore provenance), against the bundle asset + downloaded from the same release. `--bundle` makes verification offline: no + attestations-API call, so no GitHub credential — but it does *not* weaken the + check, because the bundle is signed and `--repo` still pins the signer + identity, so a substituted or tampered bundle fails. Releases publishing no + bundle asset fall back to the API path (credential required). Skipped with a + logged note if `gh` is absent, and skipped entirely when + `COOP_UPDATE_API_BASE_URL` is overridden (test mode). So provenance is *not* + guaranteed on hosts without `gh` — checksum is the floor. 5. Extraction with `tar -xzf --no-same-owner --no-same-permissions` (path-escape safe), then an atomic `rename`-over-self. diff --git a/install.sh b/install.sh index 99fbf22..d9112b1 100755 --- a/install.sh +++ b/install.sh @@ -113,22 +113,38 @@ verify_checksum() { verify_attestation() { local file="$1" - if has gh; then - info "Verifying attestation..." - # Verify offline against the bundle published with the release. `gh - # attestation verify` without --bundle queries the GitHub API, and gh - # always attaches its token, so a token lacking an SSO session for the - # org 403s on public data. The --repo identity check is still enforced. - download_asset "$BUNDLE" "${TMPDIR}/${BUNDLE}" \ - || die "Could not download ${BUNDLE} for ${VERSION} — releases before the bundle was published cannot be verified offline; install a newer version" - gh attestation verify "$file" --repo "$REPO" --bundle "${TMPDIR}/${BUNDLE}" \ - || die "Attestation verification failed for $(basename "$file") — refusing to install" - else + if ! has gh; then info "Note: \`gh\` not installed — skipped cryptographic attestation verification." info "The download was verified against the published \`SHA256SUMS\` checksum, which" info "is the same assurance level as most \`curl | bash\` installers. For end-to-end" info "Sigstore verification, install \`gh\` (https://cli.github.com) and re-run, or" - info "verify manually: \`gh attestation verify --repo ${REPO}\`." + info "verify manually: \`gh attestation verify --repo ${REPO} \\" + info " --bundle ${BUNDLE}\` against the ${BUNDLE} asset from the same release." + return 0 + fi + + info "Verifying attestation..." + # Prefer the bundle published with the release: `gh attestation verify` + # without --bundle queries the GitHub attestations API, and gh always + # attaches its stored token, so a token lacking an SSO session for the org + # 403s on public data. The --repo identity check is enforced either way. + # The probe is silenced: a missing bundle is expected on older releases, so + # curl's bare "404" would read as a hard error. + if download_asset "$BUNDLE" "${TMPDIR}/${BUNDLE}" > /dev/null 2>&1; then + gh attestation verify "$file" --repo "$REPO" --bundle "${TMPDIR}/${BUNDLE}" \ + || die "Attestation verification failed for $(basename "$file") — refusing to install" + return 0 + fi + + # Releases published before the bundle asset existed verify through the + # API, exactly as every release did before. That needs a credential + # authorized for the org, so it is the fallback and not the default. + info "No ${BUNDLE} published for ${VERSION} — verifying through the GitHub API instead." + if ! gh attestation verify "$file" --repo "$REPO"; then + info "${VERSION} predates the ${BUNDLE} asset, so verification used the GitHub API." + info "An HTTP 403 above means your GitHub credential carries no SSO session for the" + info "org; installing a release that publishes ${BUNDLE} avoids the API entirely." + die "Attestation verification failed for $(basename "$file") — refusing to install" fi } diff --git a/src/update.rs b/src/update.rs index ac2270d..e2238c6 100644 --- a/src/update.rs +++ b/src/update.rs @@ -8,6 +8,7 @@ //! to nudge users when a newer release is available. use std::env; +use std::ffi::OsString; use std::fs; use std::io::IsTerminal as _; use std::os::unix::fs::PermissionsExt as _; @@ -25,6 +26,8 @@ use crate::sha256_hash::Sha256Hash; const REPO: &str = "trailofbits/coop"; const DEFAULT_API_BASE: &str = "https://api.github.com"; +/// Release asset holding the Sigstore provenance bundle, published since #421. +const BUNDLE_ASSET: &str = "attestations.jsonl"; const DEFAULT_CHECK_INTERVAL_HOURS: u64 = 24; // ── Configuration ──────────────────────────────────────────────────────────── @@ -383,7 +386,52 @@ fn verify_sha256(file: &Path, expected: &Sha256Hash) -> Result<()> { // ── Attestation verification (best-effort) ─────────────────────────────────── -fn verify_attestation(tarball: &Path) -> Result<()> { +/// Build the `gh attestation verify` argument list. +/// +/// With `bundle`, `gh` reads the Sigstore bundle from disk and makes no API +/// call, so verification needs no GitHub credential. Without it, `gh` fetches +/// the bundle from the attestations API and always attaches its stored token — +/// which 403s on public data when that token carries no SSO session for the +/// org. `--repo` pins the signer identity in both cases. +fn attestation_verify_args(tarball: &Path, bundle: Option<&Path>) -> Vec { + let mut args: Vec = vec![ + "attestation".into(), + "verify".into(), + tarball.as_os_str().to_owned(), + "--repo".into(), + REPO.into(), + ]; + if let Some(bundle) = bundle { + args.push("--bundle".into()); + args.push(bundle.as_os_str().to_owned()); + } + args +} + +/// Download the release's provenance bundle, if it published one. +/// +/// `Ok(None)` means verification must fall back to the attestations API, or is +/// skipped outright. Skipping the download when `verify_attestation` would not +/// use it matters: the download is fallible, so fetching a bundle nothing reads +/// would turn a transient network blip into a failed update. +fn fetch_attestation_bundle(release: &Release, dir: &Path) -> Result> { + if api_base_overridden() || !command_exists("gh") { + return Ok(None); + } + let Some(asset) = release.find_asset(BUNDLE_ASSET) else { + tracing::info!( + "Release {} publishes no {BUNDLE_ASSET} — verifying the attestation through the \ + GitHub API, which requires a credential authorized for {REPO}.", + release.tag + ); + return Ok(None); + }; + let dest = dir.join(BUNDLE_ASSET); + download_asset(&release.tag, BUNDLE_ASSET, &asset.url, &dest)?; + Ok(Some(dest)) +} + +fn verify_attestation(tarball: &Path, bundle: Option<&Path>) -> Result<()> { // Skip when the API base is overridden — the local test fixture serves // synthetic artifacts that have no provenance in GitHub's attestation // API. `warn_if_api_base_overridden` has already surfaced this to the @@ -397,21 +445,26 @@ fn verify_attestation(tarball: &Path) -> Result<()> { The download was verified against the published `SHA256SUMS` checksum, which \ is the same assurance level as most `curl | bash` installers. For end-to-end \ Sigstore verification, install `gh` (https://cli.github.com) and re-run, or \ - verify manually: `gh attestation verify --repo {}`.", - REPO + verify manually: `gh attestation verify --repo {REPO} --bundle \ + {BUNDLE_ASSET}` against the {BUNDLE_ASSET} asset from the same release." ); return Ok(()); } Cmd::new("gh") - .arg("attestation") - .arg("verify") - .arg(tarball) - .arg("--repo") - .arg(REPO) + .args(attestation_verify_args(tarball, bundle)) .run() .with_context(|| { + let hint = if bundle.is_some() { + String::new() + } else { + format!( + " (verified through the GitHub API because the release publishes no \ + {BUNDLE_ASSET}; an HTTP 403 here means your GitHub credential has no \ + SSO session for the org)" + ) + }; format!( - "Attestation verification failed for {} — refusing to install", + "Attestation verification failed for {} — refusing to install{hint}", tarball.display() ) }) @@ -547,7 +600,8 @@ fn perform_update(release: &Release, triple: &str) -> Result<()> { .with_context(|| format!("{tarball_name} not listed in SHA256SUMS"))?; verify_sha256(&tarball_path, &expected)?; - verify_attestation(&tarball_path)?; + let bundle_path = fetch_attestation_bundle(release, tmp.path())?; + verify_attestation(&tarball_path, bundle_path.as_deref())?; // `--no-same-owner --no-same-permissions` ignore embedded uid/mode metadata. // `-C ` plus modern tar's default refusal of `..`-segmented and absolute @@ -893,6 +947,76 @@ mod tests { verify_sha256(&path, &wrong).unwrap_err(); } + #[test] + fn attestation_verify_args_pin_repo_and_omit_bundle_when_absent() { + let args = attestation_verify_args(Path::new("/tmp/coop.tar.gz"), None); + assert_eq!( + args, + ["attestation", "verify", "/tmp/coop.tar.gz", "--repo", REPO] + ); + } + + #[test] + fn attestation_verify_args_append_bundle_when_present() { + let args = attestation_verify_args( + Path::new("/tmp/coop.tar.gz"), + Some(Path::new("/tmp/attestations.jsonl")), + ); + assert_eq!( + args, + [ + "attestation", + "verify", + "/tmp/coop.tar.gz", + "--repo", + REPO, + "--bundle", + "/tmp/attestations.jsonl", + ] + ); + } + + #[test] + fn bundle_asset_is_found_on_a_release_that_publishes_it() { + let release: Release = serde_json::from_str(&format!( + r#"{{"tag_name":"v9.9.9","assets":[ + {{"name":"SHA256SUMS","browser_download_url":"https://example.com/S"}}, + {{"name":"{BUNDLE_ASSET}","browser_download_url":"https://example.com/B"}} + ]}}"# + )) + .unwrap(); + assert_eq!( + release.find_asset(BUNDLE_ASSET).map(|a| a.url.as_str()), + Some("https://example.com/B") + ); + + // A release predating the asset must fall back, not match something else. + let old: Release = serde_json::from_str( + r#"{"tag_name":"v0.5.4","assets":[ + {"name":"SHA256SUMS","browser_download_url":"https://example.com/S"} + ]}"#, + ) + .unwrap(); + assert!(old.find_asset(BUNDLE_ASSET).is_none()); + } + + /// The asset name is agreed across three files with no compiler link + /// between them. A rename in one silently degrades `coop update` and + /// `install.sh` back to the credential-requiring API path. + #[test] + fn bundle_asset_name_matches_release_workflow_and_installer() { + let workflow = include_str!("../.github/workflows/release.yml"); + let installer = include_str!("../install.sh"); + assert!( + workflow.contains(BUNDLE_ASSET), + "release.yml no longer publishes {BUNDLE_ASSET}" + ); + assert!( + installer.contains(BUNDLE_ASSET), + "install.sh no longer downloads {BUNDLE_ASSET}" + ); + } + #[test] fn parse_sha256sums_returns_first_of_duplicates() { let content = concat!( From fd287835ea120c121979c9fa7feed1d9b49938d6 Mon Sep 17 00:00:00 2001 From: Evan Downing <2077950+evandowning@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:31:14 -0400 Subject: [PATCH 3/3] Address review: scope failure messaging, symmetric bundle fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.sh: gate the credential explanation on gh actually reporting a 403 / SAML / "gh auth login" symptom. A network error, a gh too old for the command, or a genuine provenance mismatch previously got described as an SSO problem and pointed the user at a different release. - install.sh: confirm a successful verify on both paths — the offline bundle path printed nothing, which read the same as a skip. - update.rs: a failed bundle download now falls back to the API instead of failing the update, matching install.sh. One policy, one behavior. fetch_attestation_bundle is infallible, so it returns Option directly. - update.rs: the asset-name tripwire now asserts on the `gh release create` line. Dropping the asset from publication alone left the test green while the asset silently stopped shipping. - install.sh: record why a bundle that fails to verify is refused rather than retried — it is not stricter on integrity, it surfaces a broken download or an unusable gh. - Docs: trust-model records the failed-download fallback and the refuse-don't-retry rule; RELEASING checks for the positive confirmation line. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 2 +- RELEASING.md | 6 ++--- docs/trust-model.md | 13 ++++++----- install.sh | 27 +++++++++++++++++----- src/update.rs | 42 +++++++++++++++++++++++++---------- 5 files changed, 64 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 368bc16..bc1bd77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,7 +107,7 @@ jobs: # Normalize to one bundle per line so the published asset has a stable # shape regardless of whether the action emits a single bundle or several. - - name: Publish attestation bundle + - name: Normalize attestation bundle env: BUNDLE_PATH: ${{ steps.attest.outputs.bundle-path }} run: jq -c . "$BUNDLE_PATH" > attestations.jsonl diff --git a/RELEASING.md b/RELEASING.md index 99174dd..a64a3a7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -106,9 +106,9 @@ CI can't run the full VM integration suite or the extra-toolchain checks VERSION=vX.Y.Z INSTALL_DIR="$(mktemp -d)" bash install.sh ``` - The run must print `Verifying attestation...` without a "No - `attestations.jsonl` published" line — that line means the asset is missing - and verification silently fell back to the credential-requiring API path. + The run must print `Attestation verified offline against attestations.jsonl`. + A "No `attestations.jsonl` published" line instead means the asset is missing + and verification fell back to the credential-requiring API path. ## If the tag run fails diff --git a/docs/trust-model.md b/docs/trust-model.md index 5a45e21..a274803 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -138,11 +138,14 @@ Self-update (`update.rs`) must preserve, in order: downloaded from the same release. `--bundle` makes verification offline: no attestations-API call, so no GitHub credential — but it does *not* weaken the check, because the bundle is signed and `--repo` still pins the signer - identity, so a substituted or tampered bundle fails. Releases publishing no - bundle asset fall back to the API path (credential required). Skipped with a - logged note if `gh` is absent, and skipped entirely when - `COOP_UPDATE_API_BASE_URL` is overridden (test mode). So provenance is *not* - guaranteed on hosts without `gh` — checksum is the floor. + identity, so a substituted or tampered bundle fails. A release that publishes + no bundle asset — or a bundle whose download fails — falls back to the API + path (credential required); `update.rs` and `install.sh` treat those two + cases identically. A bundle that downloads but fails to verify is refused + outright, not retried through the API. Skipped with a logged note if `gh` is + absent, and skipped entirely when `COOP_UPDATE_API_BASE_URL` is overridden + (test mode). So provenance is *not* guaranteed on hosts without `gh` — + checksum is the floor. 5. Extraction with `tar -xzf --no-same-owner --no-same-permissions` (path-escape safe), then an atomic `rename`-over-self. diff --git a/install.sh b/install.sh index d9112b1..cd10c68 100755 --- a/install.sh +++ b/install.sh @@ -131,8 +131,14 @@ verify_attestation() { # The probe is silenced: a missing bundle is expected on older releases, so # curl's bare "404" would read as a hard error. if download_asset "$BUNDLE" "${TMPDIR}/${BUNDLE}" > /dev/null 2>&1; then + # A failure here is not retried through the API. That is no stricter on + # integrity — a substituted bundle fails here and would then verify + # correctly against the genuine attestation — but a bundle that + # downloaded and will not verify means a broken download or a `gh` that + # cannot read it, and switching transports would hide both. gh attestation verify "$file" --repo "$REPO" --bundle "${TMPDIR}/${BUNDLE}" \ || die "Attestation verification failed for $(basename "$file") — refusing to install" + info "Attestation verified offline against ${BUNDLE} — no GitHub credential used." return 0 fi @@ -140,12 +146,23 @@ verify_attestation() { # API, exactly as every release did before. That needs a credential # authorized for the org, so it is the fallback and not the default. info "No ${BUNDLE} published for ${VERSION} — verifying through the GitHub API instead." - if ! gh attestation verify "$file" --repo "$REPO"; then - info "${VERSION} predates the ${BUNDLE} asset, so verification used the GitHub API." - info "An HTTP 403 above means your GitHub credential carries no SSO session for the" - info "org; installing a release that publishes ${BUNDLE} avoids the API entirely." - die "Attestation verification failed for $(basename "$file") — refusing to install" + local out + if out="$(gh attestation verify "$file" --repo "$REPO" 2>&1)"; then + info "Attestation verified through the GitHub API." + return 0 fi + printf '%s\n' "$out" >&2 + # This path also fails on a network error, a gh too old for the command, or + # a genuine provenance mismatch, so only explain the credential requirement + # when gh actually reported one of its symptoms. + case "$out" in + *403* | *SAML* | *"gh auth login"*) + info "${VERSION} predates the ${BUNDLE} asset, so verification used the GitHub API," + info "which needs a credential authorized for the org. Installing a release that" + info "publishes ${BUNDLE} avoids the API entirely." + ;; + esac + die "Attestation verification failed for $(basename "$file") — refusing to install" } # --- main ------------------------------------------------------------------- diff --git a/src/update.rs b/src/update.rs index e2238c6..428952e 100644 --- a/src/update.rs +++ b/src/update.rs @@ -410,13 +410,15 @@ fn attestation_verify_args(tarball: &Path, bundle: Option<&Path>) -> Vec Result> { +/// `None` means verification falls back to the attestations API, or is skipped +/// outright — never that the update fails. A download that blips is the same +/// situation as a release that never published the asset, and `install.sh` +/// resolves it the same way. The download is also skipped when +/// `verify_attestation` would not read the result, so an update whose +/// attestation step is a no-op does no pointless work. +fn fetch_attestation_bundle(release: &Release, dir: &Path) -> Option { if api_base_overridden() || !command_exists("gh") { - return Ok(None); + return None; } let Some(asset) = release.find_asset(BUNDLE_ASSET) else { tracing::info!( @@ -424,11 +426,19 @@ fn fetch_attestation_bundle(release: &Release, dir: &Path) -> Result) -> Result<()> { @@ -600,7 +610,7 @@ fn perform_update(release: &Release, triple: &str) -> Result<()> { .with_context(|| format!("{tarball_name} not listed in SHA256SUMS"))?; verify_sha256(&tarball_path, &expected)?; - let bundle_path = fetch_attestation_bundle(release, tmp.path())?; + let bundle_path = fetch_attestation_bundle(release, tmp.path()); verify_attestation(&tarball_path, bundle_path.as_deref())?; // `--no-same-owner --no-same-permissions` ignore embedded uid/mode metadata. @@ -1003,13 +1013,21 @@ mod tests { /// The asset name is agreed across three files with no compiler link /// between them. A rename in one silently degrades `coop update` and /// `install.sh` back to the credential-requiring API path. + /// + /// `include_str!` is the tripwire, so moving either file breaks this test + /// as a compile error rather than a named assertion failure. #[test] fn bundle_asset_name_matches_release_workflow_and_installer() { let workflow = include_str!("../.github/workflows/release.yml"); let installer = include_str!("../install.sh"); + // The workflow names the asset twice: the `jq` output redirect that + // creates it, and the `gh release create` that publishes it. Only the + // latter makes it reachable by a client, so assert on that line. assert!( - workflow.contains(BUNDLE_ASSET), - "release.yml no longer publishes {BUNDLE_ASSET}" + workflow + .lines() + .any(|l| l.contains("gh release create") && l.contains(BUNDLE_ASSET)), + "release.yml no longer publishes {BUNDLE_ASSET} as a release asset" ); assert!( installer.contains(BUNDLE_ASSET),