From 1757bdabf566b2ef15fb4ad57410dfa22453bbc6 Mon Sep 17 00:00:00 2001 From: Deivid Soto Date: Wed, 3 Jun 2026 19:23:19 +0200 Subject: [PATCH] feat(release): sign release checksums (ed25519), enforce + bake pubkey MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Releases were shipping UNSIGNED: ship.sh never invoked sign-checksums, the goreleaser pubkey ldflag defaulted to empty, and publish-cli-release.sh did not upload a .sig — so the self-updater's signature check was silently skipped (1.0.0-beta had no checksums.txt.sig). Make signing unconditional: - internal/upgrade/signature.go: bake the canonical release public key as the compiled-in default (public, safe to commit; removes the empty-env footgun). - .goreleaser.yml: drop the pubkey ldflag (committed default is authoritative) + add a signs: block that runs scripts/sign-checksums over checksums.txt. sign-checksums requires -key, so an unset RELEASE_SIGNING_KEY fails the build instead of shipping unsigned. - scripts/ship.sh: source RELEASE_SIGNING_KEY from ~/.config/unarr-release/signing.key (or the env), die if absent, and assert checksums.txt.sig was produced. Private key lives outside the repo (gitignored keyfile + operator's vault); public key verified to match (priv[32:] == baked pubkey). --- .goreleaser.yml | 30 ++++++++++++++++++++++++++---- internal/upgrade/signature.go | 21 +++++++++++---------- scripts/ship.sh | 26 ++++++++++++++++++++++---- 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/.goreleaser.yml b/.goreleaser.yml index 6bc4a51..08a604e 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -26,10 +26,10 @@ builds: - -s -w - -X github.com/torrentclaw/unarr/internal/cmd.Version={{.Version}} - -X github.com/torrentclaw/unarr/internal/sentry.dsn={{ .Env.SENTRY_DSN }} - # Release-signing public key — verified by the self-updater against - # checksums.txt.sig. Empty when not configured; in that case - # signature verification is skipped and a warning is logged. - - -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64={{ .Env.RELEASE_SIGNING_PUBKEY }} + # The release-signing PUBLIC key is compiled in as the canonical default + # in internal/upgrade/signature.go (it's public — committing it removes + # the "empty env var → unsigned binary" footgun). No ldflag override: + # every build bakes the same key and verifies checksums.txt.sig. archives: - formats: [tar.gz] @@ -51,6 +51,28 @@ archives: checksum: name_template: "checksums.txt" +# Sign checksums.txt with the release ed25519 private key → checksums.txt.sig, +# verified by the self-updater against the compiled-in public key. Releases are +# signed UNCONDITIONALLY: sign-checksums requires -key, so an unset/empty +# RELEASE_SIGNING_KEY makes this step (and the whole `goreleaser release`) fail +# rather than silently shipping an unsigned release. ship.sh sources the key +# from ~/.config/unarr-release/signing.key (or the RELEASE_SIGNING_KEY env). +signs: + - id: checksums + cmd: go + args: + - run + - ./scripts/sign-checksums + - -key + - "{{ .Env.RELEASE_SIGNING_KEY }}" + - -in + - "${artifact}" + - -out + - "${signature}" + signature: "${artifact}.sig" + artifacts: checksum + output: true + changelog: sort: asc filters: diff --git a/internal/upgrade/signature.go b/internal/upgrade/signature.go index cfcc93d..2abd0ff 100644 --- a/internal/upgrade/signature.go +++ b/internal/upgrade/signature.go @@ -14,17 +14,18 @@ import ( // releasePubKeyBase64 is the base64-encoded ed25519 public key used to verify // `checksums.txt.sig` against `checksums.txt` during self-update. // -// It is overridable at link time via ldflags so the same source compiles for -// users who do not yet have a release-signing keypair in their CI: +// It is the canonical release-signing public key, compiled in so every build +// (local ship.sh and CI alike) verifies updates consistently — it is public, so +// committing it is safe and removes any "forgot to set the env var → shipped an +// unsigned/unverifying binary" failure mode. The matching PRIVATE key signs +// checksums.txt during release (scripts/sign-checksums, driven by the +// goreleaser `signs:` block); releases are signed unconditionally now. // -// -X github.com/torrentclaw/unarr/internal/upgrade.releasePubKeyBase64= -// -// When the variable is empty, signature verification is skipped and a warning -// is logged — checksum-only verification remains in force. This is the -// transitional default until the keypair is provisioned; flip to a non-empty -// value (and enable the corresponding CI signing step) to make signature -// verification mandatory. -var releasePubKeyBase64 = "" +// When this is empty, signature verification is skipped (a warning is logged). +// Do NOT clear it — every release from v1.0.1-beta on ships a checksums.txt.sig +// and clients built with this key require it. Rotating the key is a coordinated +// change: clients on the old key must update before the signing key flips. +var releasePubKeyBase64 = "X7EJVwAiIILs4EGaqp+YBsa4Q6HnKBB2J5FI4MIt+w0=" // ErrMissingSignature indicates the release does not ship a `.sig` file even // though signature verification is required by an embedded public key. diff --git a/scripts/ship.sh b/scripts/ship.sh index d81fd6f..3566637 100755 --- a/scripts/ship.sh +++ b/scripts/ship.sh @@ -117,16 +117,34 @@ if [ "$DRY_RUN" = false ]; then fi fi +# Release signing key — releases MUST be signed (the goreleaser `signs:` block +# consumes RELEASE_SIGNING_KEY to produce checksums.txt.sig, verified by the +# compiled-in public key). Prefer an explicit env var, else the local keyfile. +SIGNING_KEY_FILE="${RELEASE_SIGNING_KEY_FILE:-$HOME/.config/unarr-release/signing.key}" +if [ -z "${RELEASE_SIGNING_KEY:-}" ] && [ -f "$SIGNING_KEY_FILE" ]; then + RELEASE_SIGNING_KEY="$(tr -d '\r\n' < "$SIGNING_KEY_FILE")" +fi +if [ -z "${RELEASE_SIGNING_KEY:-}" ]; then + if [ "$DRY_RUN" = true ]; then + warn "no signing key (RELEASE_SIGNING_KEY env or $SIGNING_KEY_FILE) — a real ship would FAIL: releases must be signed" + else + die "no release signing key: export RELEASE_SIGNING_KEY or create $SIGNING_KEY_FILE — releases MUST be signed" + fi +fi +export RELEASE_SIGNING_KEY + if [ "$DRY_RUN" = true ]; then ok "Dry run complete — no changes made" exit 0 fi -# 1. Build -info "goreleaser build ($TAG)" -SENTRY_DSN="${SENTRY_DSN:-}" RELEASE_SIGNING_PUBKEY="${RELEASE_SIGNING_PUBKEY:-}" \ +# 1. Build (+ sign checksums via the goreleaser `signs:` block, which consumes +# RELEASE_SIGNING_KEY — exported above; missing key already aborted the run). +info "goreleaser build + sign ($TAG)" +SENTRY_DSN="${SENTRY_DSN:-}" RELEASE_SIGNING_KEY="$RELEASE_SIGNING_KEY" \ goreleaser release --clean --skip=publish -ok "dist/ ready" +[ -f dist/checksums.txt.sig ] || die "checksums.txt.sig not produced — signing step did not run" +ok "dist/ ready (checksums.txt + checksums.txt.sig)" # 2. Hetzner if [ "$SKIP_HETZNER" != "1" ]; then