Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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<VERSION>" \
# --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"
55 changes: 53 additions & 2 deletions install-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading