diff --git a/.github/workflows/prune-release-assets.yml b/.github/workflows/prune-release-assets.yml index 6f487c0..8ca4e97 100644 --- a/.github/workflows/prune-release-assets.yml +++ b/.github/workflows/prune-release-assets.yml @@ -8,7 +8,8 @@ name: prune-release-assets # Every release publishes ~6 archives at ~15.8 MB of binary each plus 4 # .rpm/.deb packages; that is the entire storage cost. Alongside them it # publishes a handful of kilobyte-sized files: checksums.txt, its keyless cosign -# signature (checksums.txt.sig) and certificate (checksums.txt.pem), and one +# signature (checksums.txt.bundle — and, on releases up to v0.22.0, the earlier +# detached checksums.txt.sig / checksums.txt.pem pair), and one # SPDX SBOM per archive/package. Those small files are the integrity and # supply-chain record an air-gapped / FSTEC-regulated consumer may have to # produce years after the binaries are gone — and because builds in this repo @@ -20,12 +21,15 @@ name: prune-release-assets # were kilobytes. # # So this workflow deletes ONLY *.tar.gz, *.zip, *.rpm, *.deb, and it keeps -# checksums.txt, *.sig, *.pem, the *_sboms.tar.gz bundle and any legacy -# *.spdx.json unconditionally (belt-and-braces: -# there is both a delete-list and an explicit always-keep list below). Do NOT +# checksums.txt, its signature (*.bundle, plus the legacy *.sig / *.pem pair), +# the *_sboms.tar.gz bundle and any legacy *.spdx.json unconditionally +# (belt-and-braces: there is both a delete-list and an explicit always-keep list +# below). Both signature spellings are on the keep-list because the two eras of +# releases carry different ones and this workflow runs across all of them. Do NOT # "simplify" this later into deleting every asset — that trades the entire audit # trail for zero additional bytes and is the one thing the design exists to -# prevent. +# prevent. Whenever the `signs:` block in .goreleaser.yaml changes the name it +# writes, the keep-list below has to learn the new suffix in the same commit. # # It never deletes a release and never touches a tag: tags and release notes are # the project's history, v* tags are immutable (a tag ruleset enforces it), and @@ -153,17 +157,24 @@ jobs: echo "protected tags: $(jq -r 'join(", ")' "$TMP/protected.json")" echo - # Heavy build outputs only. The always-keep list is redundant with the - # NOTE the SBOM bundle is koc__sboms.tar.gz — it DOES end in - # .tar.gz, so unlike the other retained files it is not naturally - # excluded by the suffix test and the always_keep guard is what saves - # it. Deleting it would leave a release whose checksums.txt still lists - # an SBOM nobody can fetch. Releases cut before the bundle landed have - # loose per-artifact documents instead, hence both patterns. - # (A per-artifact SBOM is named koc_X_os_arch.tar.gz.spdx.json, - # which does NOT end in .tar.gz) and that redundancy is deliberate: - # if someone ever loosens the delete-list, the audit-trail files still - # survive. + # Heavy build outputs only, with the always_keep list below as a second + # guard. + # + # For most retained files that guard is redundant with the suffix test: + # a per-artifact SBOM is named koc_X_os_arch.tar.gz.spdx.json, which + # does NOT end in .tar.gz, so `heavy` never matches it in the first + # place. The redundancy is deliberate — if someone later loosens the + # delete-list, the audit-trail files still survive. + # + # The SBOM bundle is the one exception: koc__sboms.tar.gz DOES end + # in .tar.gz, so always_keep is the only thing standing between it and + # deletion. Losing it would leave a release whose checksums.txt lists + # an SBOM nobody can fetch. + # + # Two spellings appear for both the SBOM and the signature because this + # workflow runs across every release: the bundle forms (_sboms.tar.gz, + # checksums.txt.bundle) from v0.23.0, and the loose forms (.spdx.json, + # checksums.txt.sig/.pem) on everything before it. jq -r --slurpfile protected "$TMP/protected.json" ' def heavy: endswith(".tar.gz") or endswith(".zip") @@ -171,7 +182,7 @@ jobs: def always_keep: . == "checksums.txt" or endswith(".spdx.json") or endswith("_sboms.tar.gz") - or endswith(".sig") or endswith(".pem"); + or endswith(".bundle") or endswith(".sig") or endswith(".pem"); ($protected[0]) as $keepTags | .[] | select(.draft | not) # never touch drafts @@ -196,8 +207,8 @@ jobs: echo echo "> Pruned versions can no longer be installed via \`brew install ftarasenko/tap/koc\`," echo "> and any mirror that downloads koc binaries from GitHub on demand will 404 for them." - echo "> Tags, release notes, \`checksums.txt\`(+\`.sig\`/\`.pem\`) and the SPDX SBOMs are retained," - echo "> and builds are byte-reproducible from the tag." + echo "> Tags, release notes, \`checksums.txt\`(+ its \`.bundle\`, or \`.sig\`/\`.pem\` on older" + echo "> releases) and the SPDX SBOMs are retained, and builds are byte-reproducible from the tag." fi echo echo "Keep window: **$KEEP** most recent non-draft, non-prerelease releases; latest (\`${LATEST_TAG:-none}\`) always kept." @@ -295,7 +306,8 @@ jobs: printf '> **Build artifacts pruned to save storage.** The archives and packages\n' printf '> (`.tar.gz`, `.zip`, `.rpm`, `.deb`) for this release have been deleted. The tag,\n' printf '> these release notes, `checksums.txt` with its cosign signature\n' - printf '> (`checksums.txt.sig`/`.pem`) and the SPDX SBOMs are retained. Builds are\n' + printf '> (`checksums.txt.bundle`, or `.sig`/`.pem` on releases up to v0.22.0) and the\n' + printf '> SPDX SBOMs are retained. Builds are\n' printf '> byte-reproducible from tag `%s`, so the binaries can be rebuilt and verified\n' "$tag" printf '> against the retained `checksums.txt`. Consequence: `brew install` and any\n' printf '> on-demand mirror will no longer find binaries for this version — use a newer\n' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 040adae..26ec332 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,11 +91,16 @@ jobs: # cosign and syft are required by the `signs:` and `sboms:` blocks in # .goreleaser.yaml. Both are official installers from the projects - # themselves. - # Pinned to full commit SHAs per repo convention. Both are still on their - # original major here; Dependabot's github-actions ecosystem proposes major - # bumps as reviewable PRs (cosign-installer v4 and sbom-action have both - # moved on), which is the right place to verify changed inputs. + # themselves, pinned to full commit SHAs per repo convention. + # + # cosign-installer v4 installs cosign v3 by default, and v3 turns the + # standardized Sigstore bundle on by default — which is why the `signs:` + # block writes checksums.txt.bundle rather than a detached .sig/.pem pair. + # The version is deliberately NOT pinned via `cosign-release`: the two + # move together, and letting the installer choose keeps the tool current + # with Dependabot's reviewable bumps. If that default ever has to be + # pinned, the floor is v3 — the v2 flags this repo used before are ignored + # under the new bundle format (see the note in .goreleaser.yaml). - name: Install cosign uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 @@ -121,9 +126,11 @@ jobs: # proves what produced them. Verify with: # gh attestation verify koc__linux_amd64.tar.gz \ # --repo ftarasenko/go-openstackclient - # Pinned to a full commit SHA per repo convention. Held at v2 because the - # inputs below were written against it; v4 is current, and Dependabot will - # propose that bump as a reviewable PR. + # Pinned to a full commit SHA per repo convention. On v4, which upstream + # has reduced to a thin wrapper over actions/attest — `subject-path` still + # behaves as it did on v2, which is the only input used here. New work + # should reach for actions/attest directly; this stays until there is a + # reason to move it. - name: Attest build provenance uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index 6bd4895..5520eb9 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -103,9 +103,9 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Pinned to a full commit SHA per repo convention. Held on v4 because the - # inputs below were written against it; v5 is current, and Dependabot will - # propose that bump as a reviewable PR. + # Pinned to a full commit SHA per repo convention. On v5, whose only + # breaking change was the node24 runtime (needs runner >= v2.327.1, which + # ubuntu-latest satisfies); both inputs below are unchanged from v4. - name: Dependency review uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 474b085..40236fd 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -145,29 +145,38 @@ sboms: # Keyless cosign signature over checksums.txt. checksums.txt alone is not an # integrity story — whoever can swap an artifact in the release can swap the -# checksums with it — but a Sigstore-backed detached signature over it is: the -# signature roots the trust and the checksums chain to every artifact. +# checksums with it — but a Sigstore-backed signature over it is: the signature +# roots the trust and the checksums chain to every artifact. # -# Verify (needs the cert, the sig and the file; no key distribution): -# cosign verify-blob checksums.txt \ -# --certificate checksums.txt.pem --signature checksums.txt.sig \ +# Verify (the bundle carries signature, certificate and transparency-log +# inclusion proof, so it is the only extra file needed; no key distribution): +# cosign verify-blob checksums.txt --bundle checksums.txt.bundle \ # --certificate-identity-regexp 'https://github.com/ftarasenko/go-openstackclient/.*' \ # --certificate-oidc-issuer https://token.actions.githubusercontent.com # sha256sum -c checksums.txt # +# --new-bundle-format is cosign v3's default, but it is named explicitly here on +# purpose: the previous config passed --output-signature/--output-certificate, +# and when that default flipped from false to true in v3 those flags became +# silently IGNORED ("deprecated when using --new-bundle-format and will be +# ignored") rather than an error. The release then produced no .sig/.pem at all, +# and because release.yml only runs on a v* tag, nothing would have caught it +# before the tag was already burned. Stating the format keeps this block correct +# under either default, and it is what makes an air-gapped `--trusted-root` +# verification possible (that flag requires the new format) — see SECURITY.md. +# # Needs `id-token: write` in the release workflow (the OIDC token is what # Fulcio signs) plus cosign on PATH — both wired in release.yml. signs: - id: cosign-checksums cmd: cosign artifacts: checksum - signature: "${artifact}.sig" - certificate: "${artifact}.pem" + signature: "${artifact}.bundle" output: true args: - sign-blob - - "--output-signature=${signature}" - - "--output-certificate=${certificate}" + - "--new-bundle-format" + - "--bundle=${signature}" - "${artifact}" - "--yes" diff --git a/AGENTS.md b/AGENTS.md index 0f509ed..22f707c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -373,12 +373,14 @@ weakened by a check that happens to need a proxy. - `.github/workflows/prune-release-assets.yml` — dispatch (`dry_run` defaults to true) to reclaim storage by deleting `.tar.gz`/`.zip`/`.rpm`/`.deb` from releases outside a keep window, while **retaining** `checksums.txt`, its - signature/certificate and the SBOM bundle — note the bundle ends in `.tar.gz`, - so its always-keep guard, not the suffix filter, is what protects it. Because - builds are byte-reproducible from + cosign bundle (plus the older detached signature/certificate) and the SBOM + bundle — note the SBOM bundle ends in `.tar.gz`, so its always-keep guard, not + the suffix filter, is what protects it. Because builds are byte-reproducible from the tag, a pruned release stays verifiable and re-derivable; deleting the checksums would end that, which is why the retain list is redundant with the delete list on purpose. Pruned versions stop installing via the Homebrew cask. + Its keep-list is keyed on asset **suffix**, so changing what `signs:` writes + means editing that jq filter in the same commit. `checksums.txt` on its own is not an integrity story: whoever can swap an artifact in a release can swap the checksums with it. The cosign signature is what roots @@ -386,6 +388,21 @@ the trust, and the checksums then chain to every artifact. The verification command (no key distribution needed) is in `.goreleaser.yaml` next to the `signs:` block and in README "Prebuilt binaries". +**The signature is a Sigstore bundle (`checksums.txt.bundle`), not a detached +`.sig`/`.pem` pair.** cosign v3 made the standardized bundle the default, and the +migration was not optional: under that default the `--output-signature` / +`--output-certificate` flags the old config passed are *silently ignored* rather +than rejected, so the release would have published no signature at all — and +since `release.yml` only runs on a `v*` tag, no PR check could have caught it. +The bundle also carries the transparency-log inclusion proof, which is what makes +the air-gapped `--trusted-root` verification in SECURITY.md possible; cosign v3 +deprecated `--offline` in favour of exactly that. Three consequences to keep in +mind when touching this: verifying needs cosign v3+ (or v2.6+ with an explicit +`--new-bundle-format`); releases up to v0.22.0 still carry the old pair and a v3 +client falls back to it automatically; and `.goreleaser.yaml` names +`--new-bundle-format` explicitly so a future default flip cannot repeat the +failure silently. + **GoReleaser has no `before:` hooks, on purpose.** Hooks run inside the same workflow step as GoReleaser, and that step carries `HOMEBREW_TAP_TOKEN` — a PAT with write access to a second repository. A `go run ./cmd/koc completion …` hook diff --git a/README.md b/README.md index ea4ec8f..dd9752f 100644 --- a/README.md +++ b/README.md @@ -77,14 +77,13 @@ darwin/amd64, darwin/arm64, windows/amd64, windows/arm64** with a Alongside them: **`.rpm` and `.deb` packages** for the two linux architectures (for a local yum/apt mirror on an air-gapped node — these do install the shell completions), an **SPDX SBOM bundle** (`koc__sboms.tar.gz`, one -`*.spdx.json` per artifact inside), a keyless **cosign -signature** over `checksums.txt` (`checksums.txt.sig` + `.pem`), and a GitHub +`*.spdx.json` per artifact inside), a keyless **cosign signature** over +`checksums.txt` (`checksums.txt.bundle`), and a GitHub **build-provenance attestation**. Builds are byte-reproducible, so the checksums can be re-derived independently from the tag. ```sh -cosign verify-blob checksums.txt \ - --certificate checksums.txt.pem --signature checksums.txt.sig \ +cosign verify-blob checksums.txt --bundle checksums.txt.bundle \ --certificate-identity-regexp 'https://github.com/ftarasenko/go-openstackclient/.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com sha256sum -c checksums.txt @@ -98,12 +97,23 @@ The two identity flags are the part that matters — without them you have check only that *somebody* signed the file. Verify the signature first, then let `sha256sum -c` carry that trust to each artifact. +`checksums.txt.bundle` is a [Sigstore bundle][sigstore-bundle]: it carries the +signature, the signing certificate and the transparency-log inclusion proof in +one file, so nothing else has to be downloaded alongside it. Reading it needs +**cosign v3 or newer** (where the format is the default), or cosign v2.6+ with an +explicit `--new-bundle-format`. Releases up to and including **v0.22.0** instead +carry a detached `checksums.txt.sig` + `.pem` pair; a v3 client still verifies +those — pass `--certificate`/`--signature` in place of `--bundle` and it falls +back to the legacy path automatically. + Both commands reach the network by default (`cosign` consults the public Rekor -log, `gh` calls the GitHub API), so on an isolated network capture a -`--bundle` on a connected host and verify from it with `cosign verify-blob ---bundle … --offline`. `sha256sum -c checksums.txt` is fully offline and is what -you gate the install on. See [SECURITY.md](SECURITY.md) for the air-gapped -procedure, the supported-version policy, and how to report a vulnerability. +log, `gh` calls the GitHub API). On an isolated network, verify the bundle +against a `--trusted-root` file captured on a connected host — no Rekor call, no +`--offline` flag. `sha256sum -c checksums.txt` is fully offline and is what you +gate the install on. See [SECURITY.md](SECURITY.md) for that procedure, the +supported-version policy, and how to report a vulnerability. + +[sigstore-bundle]: https://blog.sigstore.dev/cosign-3-0-available/ ## Build diff --git a/SECURITY.md b/SECURITY.md index b0dbd7b..f0090dd 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -75,8 +75,7 @@ checksums chain from it to each artifact. Verify the signature, then the artifact: ```sh -cosign verify-blob checksums.txt \ - --certificate checksums.txt.pem --signature checksums.txt.sig \ +cosign verify-blob checksums.txt --bundle checksums.txt.bundle \ --certificate-identity-regexp 'https://github.com/ftarasenko/go-openstackclient/.*' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com @@ -88,6 +87,16 @@ itself, asserted by the certificate. The `--certificate-identity-regexp` and `--certificate-oidc-issuer` flags are the check that matters — without them you have verified only that *somebody* signed the file. +`checksums.txt.bundle` is a Sigstore bundle carrying the signature, the signing +certificate and the transparency-log inclusion proof together, so it is the only +file to fetch beside `checksums.txt` itself. Reading it needs **cosign v3+**, +where that format is the default, or cosign v2.6+ with an explicit +`--new-bundle-format`. Releases up to and including **v0.22.0** shipped a +detached `checksums.txt.sig` + `checksums.txt.pem` pair instead; substitute +`--certificate checksums.txt.pem --signature checksums.txt.sig` for `--bundle` +and a v3 client verifies those too — it detects the legacy layout and falls back +on its own. + Provenance — what built the artifact, from which commit and workflow: ```sh @@ -112,25 +121,37 @@ re-derive from a rebuild. ### Verifying without internet access The commands above are the reason the air-gap matters here: by default -`cosign verify-blob` consults the public Rekor transparency log and +`cosign verify-blob` fetches Sigstore's trust roots over TUF and `gh attestation verify` calls the GitHub API, so both fail on an isolated network. -For an air-gapped check, do the verification once on a connected host and carry -the result across, or verify offline with the transparency-log bundle: +The bundle already contains the signature, the certificate and the +transparency-log inclusion proof, so the only thing still missing inside the +enclave is the trust anchor those are checked against. Capture it once on a +connected host as a **trusted root** file, carry it across with the release +artifacts, and the verification runs with no network at all: ```sh -# On a connected host, capture the bundle alongside the artifacts: -cosign verify-blob checksums.txt \ - --certificate checksums.txt.pem --signature checksums.txt.sig \ - --certificate-identity-regexp 'https://github.com/ftarasenko/go-openstackclient/.*' \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - --bundle checksums.txt.bundle +# On a connected host, once per trust-root rotation (not per release): +cosign trusted-root create --with-default-services --out trusted_root.json -# Inside the enclave, verify from the bundle without contacting Rekor: -cosign verify-blob checksums.txt --bundle checksums.txt.bundle --offline +# Inside the enclave — no Rekor call, no TUF fetch: +cosign verify-blob checksums.txt --bundle checksums.txt.bundle \ + --trusted-root trusted_root.json \ + --certificate-identity-regexp 'https://github.com/ftarasenko/go-openstackclient/.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com ``` +Keep the identity flags on the enclave-side command. They are what bind the +signature to *this* repository's release workflow; a `--trusted-root` on its own +only proves the certificate came from Sigstore, which any GitHub workflow can +obtain. + +`--trusted-root` requires the new bundle format, which is why it works from +v0.23.0 onward. For an older release, the equivalent is to run the full +verification on the connected host and carry the *result* across, since the +detached `.sig`/`.pem` pair has no inclusion proof to check offline against. + `gh attestation verify` accepts `--bundle` for the same purpose; download the attestation bundle on the connected side and pass it in.