Skip to content

feat(release): sign checksums.txt with cosign keyless signing - #903

Draft
mcdgavin wants to merge 3 commits into
livekit:mainfrom
mcdgavin:feat/release-signing
Draft

feat(release): sign checksums.txt with cosign keyless signing#903
mcdgavin wants to merge 3 commits into
livekit:mainfrom
mcdgavin:feat/release-signing

Conversation

@mcdgavin

@mcdgavin mcdgavin commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #880, which verifies archives against checksums.txt — but the checksums file ships alongside the archive in the same release, so it can't detect substituted release assets. This PR signs releases so the checksums are bound to an identity an attacker can't forge.

Approach: keyless (Sigstore) signing of checksums.txt

  • .goreleaser.yaml: a signs section runs cosign sign-blob on checksums.txt during release. Keyless — cosign uses the release workflow's GitHub OIDC identity, so there is no signing key to store or rotate. Each release gains checksums.txt.sig + checksums.txt.pem, and the signature is recorded in the Rekor transparency log. Since every archive is listed in checksums.txt, one signature covers all release artifacts.
  • .github/workflows/release.yaml: adds a sigstore/cosign-installer step (pinned by SHA, matching repo convention) and id-token: write scoped to the release job only — the Fulcio certificate identity is per-workflow, not per-job, so granting id-token workflow-wide would let the docker job's third-party actions mint the exact identity installers trust.
  • install-cli.sh: when cosign is installed, verifies the signature with the certificate identity pinned (exact match, no regexp) to this repo's release workflow at the exact version tag. The check fails closed where it matters:
    • Missing .sig/.pem on any release after v2.17.0 (the last unsigned release) aborts — an attacker who substitutes assets can't strip the signature to force a checksum-only fallback.
    • Only a clean 404 on a pre-signing release skips verification; transport errors and other HTTP failures abort instead of being conflated with "not published".
    • When cosign is absent, it logs a skip and falls back to checksum-only — nothing breaks for users without cosign or for existing releases.

Manual verification for users (substitute the exact version you downloaded — pinning the tag rejects signatures replayed from an older release):

cosign verify-blob \
  --certificate checksums.txt.pem --signature checksums.txt.sig \
  --certificate-identity "https://github.com/livekit/livekit-cli/.github/workflows/release.yaml@refs/tags/v<VERSION>" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  checksums.txt

Testing

  • goreleaser check passes on the modified config.
  • The installer's signature block was exercised in isolation across all paths: no cosign → skip; pre-signing release (404) → skip; post-v2.17.0 release with missing signature → abort; transport error / mixed HTTP statuses → abort; valid sig → continue; invalid sig → abort.
  • fetch_status (the HTTP-status-aware download helper) tested against the real v2.17.0 release assets: 200 for checksums.txt, 404 for the nonexistent .sig, 000 on transport failure — all without tripping errexit.
  • Full installer run end-to-end in an Ubuntu container against the real v2.17.0 release, both with and without cosign installed (pre-hardening revision).
  • Not testable from a fork: the signing step itself needs a real tag push with GitHub OIDC in this repo. A maintainer could confirm with a pre-release tag (e.g. v0.0.0-signing-test) before the next real release.

Note for maintainers: if a release ships before this PR lands, bump LAST_UNSIGNED_VERSION in install-cli.sh to that release.

Possible follow-ups (out of scope here)

  • Emit a Sigstore bundle (--bundle) at sign time and verify with --bundle --offline, removing the per-install Rekor lookup and TUF fetch (also de-flakes install-test.yaml, which runs the installer with cosign on every PR).
  • Version-gate the cosign check so a stale cosign 1.x (no --certificate-identity flag) skips with a message instead of hard-aborting.
  • cosign-sign the Docker images in the docker job (same keyless flow on the pushed digest).
  • GitHub artifact attestations (actions/attest-build-provenance) as a cheap complement — users could verify with gh attestation verify.

Sign releases via Sigstore keyless signing: goreleaser invokes cosign
sign-blob on checksums.txt using the release workflow's GitHub OIDC
identity, so there is no signing key to store or rotate. Each release
gains checksums.txt.sig and checksums.txt.pem, and the signature is
recorded in the Rekor transparency log.

install-cli.sh verifies the signature opportunistically: when cosign is
installed it pins the certificate identity to this repo's release
workflow at the exact version tag, aborting on an invalid signature.
Releases that predate signing (no .sig/.pem assets) and systems without
cosign fall back to the existing checksum-only verification.
mcdgavin added 2 commits July 26, 2026 10:18
Address review findings on the signing PR:

- Fail closed when the signature is missing for a release that is known
  to be signed (any version after LAST_UNSIGNED_VERSION=2.17.0), so an
  attacker who can substitute release assets cannot strip checksums.txt.sig
  to force a silent checksum-only fallback.
- Distinguish a genuine 404 (release predates signing) from transport
  errors and other HTTP failures when fetching .sig/.pem; anything but a
  clean 404 now aborts instead of logging 'no signature published' and
  proceeding unverified.
- Scope id-token: write to the release job instead of the whole workflow;
  the Fulcio certificate identity is per-workflow, not per-job, so the
  docker job's third-party actions must not be able to mint the identity
  installers trust.
- install-cli.sh: switch cosign verify-blob from
  --certificate-identity-regexp to --certificate-identity. The expected
  identity is fully known, and the regexp form left $VERSION's dots as
  wildcards (v2.4.1 also matched a cert for tag v2x4y1); exact matching
  removes the escaping burden entirely.
- .goreleaser.yaml: the documented verify recipe pinned the identity with
  '@.*$', which accepts a signature replayed from any older signed
  release. Document the exact-identity form with the tag substituted
  instead, matching what the installer enforces.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant