diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 1d793fd6..7acebdab 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -30,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 @@ -92,6 +99,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 5797ffd4..c54fa6d5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -161,5 +161,28 @@ 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 (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 "https://github.com/livekit/livekit-cli/.github/workflows/release.yaml@refs/tags/v" \ +# --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 eee74792..dd0957f1 100755 --- a/install-cli.sh +++ b/install-cli.sh @@ -73,10 +73,61 @@ 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. +# +# 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 + 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" \ + --signature "$TEMP_DIR/checksums.txt.sig" \ + --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" + 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 + abort "Could not download the signature for checksums.txt (HTTP $sig_status/$pem_status)" + 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; 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"