From 1710d1e13a5f92d6276900652857d4d306b8225a Mon Sep 17 00:00:00 2001 From: gavin mcdonough Date: Fri, 10 Jul 2026 14:14:35 -0400 Subject: [PATCH 1/3] feat(release): sign checksums.txt with cosign keyless signing 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. --- .github/workflows/release.yaml | 6 ++++++ .goreleaser.yaml | 22 ++++++++++++++++++++++ install-cli.sh | 30 ++++++++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1d793fd66..cbd93d159 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,6 +21,9 @@ on: permissions: contents: write + # id-token lets cosign obtain a GitHub OIDC token for keyless (Sigstore) + # signing of checksums.txt during the goreleaser step. + id-token: write jobs: # GoReleaser runs on macOS so the darwin binary builds natively (CoreAudio @@ -92,6 +95,9 @@ jobs: path: .cross key: cross-${{ runner.os }}-zig0.14.1-alsa1.2.12-${{ hashFiles('scripts/setup-cross.sh') }} + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Run GoReleaser uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7 with: diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 5797ffd4f..3d86dc5a0 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -161,5 +161,27 @@ gomod: checksum: name_template: 'checksums.txt' +# Keyless (Sigstore) signing: cosign signs checksums.txt with the release +# workflow's GitHub OIDC identity — no long-lived key to manage. Every archive +# is listed in checksums.txt, so one signature covers all release artifacts. +# Verify with: +# cosign verify-blob \ +# --certificate checksums.txt.pem --signature checksums.txt.sig \ +# --certificate-identity-regexp '^https://github.com/livekit/livekit-cli/\.github/workflows/release\.yaml@.*$' \ +# --certificate-oidc-issuer https://token.actions.githubusercontent.com \ +# checksums.txt +signs: + - cmd: cosign + signature: "${artifact}.sig" + certificate: "${artifact}.pem" + args: + - sign-blob + - --output-signature=${signature} + - --output-certificate=${certificate} + - --yes + - ${artifact} + artifacts: checksum + output: true + snapshot: version_template: "{{ incpatch .Version }}-next" diff --git a/install-cli.sh b/install-cli.sh index eee747923..1e968c425 100755 --- a/install-cli.sh +++ b/install-cli.sh @@ -73,10 +73,36 @@ trap 'rm -rf "$TEMP_DIR"' EXIT curl -fsSL "$ARCHIVE_URL" -o "$TEMP_DIR/$ARCHIVE_NAME" curl -fsSL "$CHECKSUMS_URL" -o "$TEMP_DIR/checksums.txt" +# When cosign is available, verify the Sigstore signature on checksums.txt. The +# certificate identity is pinned to this repo's release workflow running for this +# exact version tag, so a valid signature proves the checksums were produced by +# livekit-cli's release CI and not substituted alongside a tampered archive. +# Releases published before signing was introduced have no .sig/.pem assets, so a +# missing signature is skipped rather than fatal; a present-but-invalid one aborts. +if command -v cosign >/dev/null; then + # No -S here: a 404 is the expected outcome for releases that predate signing, + # so curl's error output would just be noise. + if curl -fsL "$CHECKSUMS_URL.sig" -o "$TEMP_DIR/checksums.txt.sig" && + curl -fsL "$CHECKSUMS_URL.pem" -o "$TEMP_DIR/checksums.txt.pem"; then + log "Verifying signature..." + cosign verify-blob \ + --certificate "$TEMP_DIR/checksums.txt.pem" \ + --signature "$TEMP_DIR/checksums.txt.sig" \ + --certificate-identity-regexp "^https://github.com/livekit/$REPO/\.github/workflows/release\.yaml@refs/tags/v${VERSION}$" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "$TEMP_DIR/checksums.txt" \ + || abort "Signature verification failed for checksums.txt" + else + log "No signature published for this release; skipping signature verification." + fi +else + log "cosign not found; skipping signature verification." +fi + # Verify the archive against the release's checksums.txt before extracting. The checksums # file is fetched from the same release over HTTPS, so this guards against corrupted/partial -# downloads and accidental mismatches; it is not a substitute for signature verification -# against an out-of-band key. +# downloads and accidental mismatches; when cosign is absent it is not a substitute for +# signature verification against an out-of-band key. log "Verifying checksum..." expected_sum=$(awk -v f="$ARCHIVE_NAME" '$2 == f {print $1}' "$TEMP_DIR/checksums.txt") [ -n "$expected_sum" ] || abort "Could not find a checksum for $ARCHIVE_NAME in checksums.txt" From f4b798834ebc76d62dd4c962fa3f186eb4e5caa2 Mon Sep 17 00:00:00 2001 From: gavin mcdonough Date: Sun, 26 Jul 2026 10:18:49 -0400 Subject: [PATCH 2/3] fix(release): harden installer signature verification and scope id-token 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. --- .github/workflows/release.yaml | 10 +++++--- install-cli.sh | 43 +++++++++++++++++++++++++++------- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cbd93d159..7acebdab7 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -21,9 +21,6 @@ on: permissions: contents: write - # id-token lets cosign obtain a GitHub OIDC token for keyless (Sigstore) - # signing of checksums.txt during the goreleaser step. - id-token: write jobs: # GoReleaser runs on macOS so the darwin binary builds natively (CoreAudio @@ -33,6 +30,13 @@ jobs: release: name: Release runs-on: macos-latest + # id-token lets cosign obtain a GitHub OIDC token for keyless (Sigstore) + # signing of checksums.txt during the goreleaser step. Scoped to this job + # only: the Fulcio certificate identity is per-workflow, not per-job, so + # any job holding id-token could sign as the identity installers trust. + permissions: + contents: write + id-token: write steps: - name: Checkout Git LFS uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 diff --git a/install-cli.sh b/install-cli.sh index 1e968c425..1e0e7890c 100755 --- a/install-cli.sh +++ b/install-cli.sh @@ -77,13 +77,33 @@ curl -fsSL "$CHECKSUMS_URL" -o "$TEMP_DIR/checksums.txt" # certificate identity is pinned to this repo's release workflow running for this # exact version tag, so a valid signature proves the checksums were produced by # livekit-cli's release CI and not substituted alongside a tampered archive. -# Releases published before signing was introduced have no .sig/.pem assets, so a -# missing signature is skipped rather than fatal; a present-but-invalid one aborts. +# +# Every release after v$LAST_UNSIGNED_VERSION publishes checksums.txt.sig/.pem, +# so for those a missing signature means the assets were tampered with (an +# attacker stripping the signature to force a checksum-only fallback) and is +# fatal. Only releases predating signing may legitimately lack one, and only a +# clean 404 counts as "not published" — transport errors and other HTTP +# failures abort rather than silently skipping verification. +# +# NOTE for maintainers: if a release ships between this script landing and the +# signing config landing, bump LAST_UNSIGNED_VERSION to that release. +LAST_UNSIGNED_VERSION="2.17.0" + +version_gt() { + [ "$1" != "$2" ] && [ "$(printf '%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ] +} + +# Downloads $1 to $2 and prints the final HTTP status code; transport failures +# (DNS, TLS, reset) print curl's 000. Never returns non-zero, so callers can +# branch on the status code under errexit. +fetch_status() { + curl -fsL -o "$2" -w '%{http_code}' "$1" 2>/dev/null || true +} + if command -v cosign >/dev/null; then - # No -S here: a 404 is the expected outcome for releases that predate signing, - # so curl's error output would just be noise. - if curl -fsL "$CHECKSUMS_URL.sig" -o "$TEMP_DIR/checksums.txt.sig" && - curl -fsL "$CHECKSUMS_URL.pem" -o "$TEMP_DIR/checksums.txt.pem"; then + sig_status=$(fetch_status "$CHECKSUMS_URL.sig" "$TEMP_DIR/checksums.txt.sig") + pem_status=$(fetch_status "$CHECKSUMS_URL.pem" "$TEMP_DIR/checksums.txt.pem") + if [ "$sig_status" = "200" ] && [ "$pem_status" = "200" ]; then log "Verifying signature..." cosign verify-blob \ --certificate "$TEMP_DIR/checksums.txt.pem" \ @@ -92,8 +112,13 @@ if command -v cosign >/dev/null; then --certificate-oidc-issuer https://token.actions.githubusercontent.com \ "$TEMP_DIR/checksums.txt" \ || abort "Signature verification failed for checksums.txt" + elif [ "$sig_status" = "404" ] && [ "$pem_status" = "404" ]; then + if version_gt "$VERSION" "$LAST_UNSIGNED_VERSION"; then + abort "No signature published for v$VERSION, but every release after v$LAST_UNSIGNED_VERSION is signed; refusing to install." + fi + log "Release predates signing; skipping signature verification." else - log "No signature published for this release; skipping signature verification." + abort "Could not download the signature for checksums.txt (HTTP $sig_status/$pem_status)" fi else log "cosign not found; skipping signature verification." @@ -101,8 +126,8 @@ fi # Verify the archive against the release's checksums.txt before extracting. The checksums # file is fetched from the same release over HTTPS, so this guards against corrupted/partial -# downloads and accidental mismatches; when cosign is absent it is not a substitute for -# signature verification against an out-of-band key. +# downloads and accidental mismatches; unless the signature was verified above, it is not +# a substitute for signature verification against an out-of-band key. log "Verifying checksum..." expected_sum=$(awk -v f="$ARCHIVE_NAME" '$2 == f {print $1}' "$TEMP_DIR/checksums.txt") [ -n "$expected_sum" ] || abort "Could not find a checksum for $ARCHIVE_NAME in checksums.txt" From fd5372698129a02faed914567cd75cb9b0b28029 Mon Sep 17 00:00:00 2001 From: gavin mcdonough Date: Sun, 26 Jul 2026 10:24:15 -0400 Subject: [PATCH 3/3] fix(release): pin exact certificate identity, no regexp - 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. --- .goreleaser.yaml | 5 +++-- install-cli.sh | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 3d86dc5a0..c54fa6d51 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -164,10 +164,11 @@ checksum: # Keyless (Sigstore) signing: cosign signs checksums.txt with the release # workflow's GitHub OIDC identity — no long-lived key to manage. Every archive # is listed in checksums.txt, so one signature covers all release artifacts. -# Verify with: +# Verify with (substitute the exact version you downloaded — pinning the tag +# rejects signatures replayed from a different, older release): # cosign verify-blob \ # --certificate checksums.txt.pem --signature checksums.txt.sig \ -# --certificate-identity-regexp '^https://github.com/livekit/livekit-cli/\.github/workflows/release\.yaml@.*$' \ +# --certificate-identity "https://github.com/livekit/livekit-cli/.github/workflows/release.yaml@refs/tags/v" \ # --certificate-oidc-issuer https://token.actions.githubusercontent.com \ # checksums.txt signs: diff --git a/install-cli.sh b/install-cli.sh index 1e0e7890c..dd0957f17 100755 --- a/install-cli.sh +++ b/install-cli.sh @@ -108,7 +108,7 @@ if command -v cosign >/dev/null; then cosign verify-blob \ --certificate "$TEMP_DIR/checksums.txt.pem" \ --signature "$TEMP_DIR/checksums.txt.sig" \ - --certificate-identity-regexp "^https://github.com/livekit/$REPO/\.github/workflows/release\.yaml@refs/tags/v${VERSION}$" \ + --certificate-identity "https://github.com/livekit/$REPO/.github/workflows/release.yaml@refs/tags/v${VERSION}" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ "$TEMP_DIR/checksums.txt" \ || abort "Signature verification failed for checksums.txt"