From a069e72f3bc5909d39dbadbd8bf1513e814cb976 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 00:18:06 -0300 Subject: [PATCH 1/2] feat(selfupdate): keyless cosign signing in release + verify-on-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire keyless cosign (sigstore) signing into the release pipeline and add signature verification to the self-update flow (spec 14 + spec 25). Release pipeline: - release.yml gains `id-token: write` and a sigstore/cosign-installer step. - .goreleaser.yaml `signs:` block runs `cosign sign-blob --yes` (keyless, GitHub OIDC — no private key) over checksums.txt, publishing checksums.txt.sig + checksums.txt.pem as release assets. Verify-on-update (internal/selfupdate): - New Verifier interface (verify.go) shelling `cosign verify-blob`, pinning --certificate-identity-regexp to the release.yml workflow identity and --certificate-oidc-issuer to token.actions.githubusercontent.com. - downloadBinary verifies the checksums.txt signature (the trust root) BEFORE trusting any per-archive SHA-256 line; SHA-256 is always enforced. - --insecure-skip-verify escape hatch (default verify-on). cosign absent aborts with a clear remediation unless bypassed. - Keeps the existing IsDevBuild + package-manager CanSelfReplace refusals. Tests: happy-path verify, tamper-detection, verify-before-checksum ordering, cosign-unavailable abort, missing-signature abort, skip-verify bypass (SHA still enforced), and a release.yml YAML-parse assert that id-token: write is declared. No network required. Default: signature verification is ON; it requires the cosign binary. When cosign is absent the update refuses with a remediation rather than silently degrading — pass --insecure-skip-verify to proceed on checksum-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release.yml | 4 + .goreleaser.yaml | 27 +++- internal/cli/self.go | 22 ++- internal/selfupdate/selfupdate_test.go | 15 +- internal/selfupdate/update.go | 40 ++++- internal/selfupdate/verify.go | 141 +++++++++++++++++ internal/selfupdate/verify_test.go | 203 +++++++++++++++++++++++++ 7 files changed, 433 insertions(+), 19 deletions(-) create mode 100644 internal/selfupdate/verify.go create mode 100644 internal/selfupdate/verify_test.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb41878..4990176 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,7 @@ on: permissions: contents: write # tag push + goreleaser GitHub Release, both via GITHUB_TOKEN + id-token: write # keyless cosign signing: mint a GitHub OIDC token for Fulcio concurrency: group: release-${{ github.ref }} @@ -44,6 +45,9 @@ jobs: with: go-version: "1.25" check-latest: true + # cosign for KEYLESS release signing (goreleaser's `signs:` block shells it). + # Keyless uses the job's OIDC token (id-token: write above) — no private key. + - uses: sigstore/cosign-installer@v3 # --- automated path (push to main / workflow_dispatch): compute + tag --- - name: install svu (pinned) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index ac77393..52adb2e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -49,7 +49,32 @@ checksum: name_template: "checksums.txt" algorithm: sha256 -# cosign/minisign signing is deferred to post-v1 (DECISIONS D13). +# Keyless cosign signing (spec 14 + spec 25). We sign checksums.txt — the ROOT of +# the integrity chain: internal/selfupdate verifies this signature first, then +# trusts every per-archive SHA-256 line in checksums.txt (each archive is checked +# against it). Signing just checksums.txt (not each archive) keeps a single +# signature to publish and is exactly what the client verifies. +# +# `cosign sign-blob` is KEYLESS: it uses the GitHub Actions OIDC token (the +# workflow requests `id-token: write`) to obtain a short-lived Fulcio cert — no +# long-lived private key is created or stored. It emits: +# checksums.txt.sig — the signature (base64) +# checksums.txt.pem — the Fulcio signing certificate (identity = the workflow) +# both uploaded as release assets (output: true) for the client to fetch and pass +# to `cosign verify-blob` with a pinned identity + OIDC issuer. +signs: + - id: checksums-keyless + cmd: cosign + signature: "${artifact}.sig" + certificate: "${artifact}.pem" + artifacts: checksum # sign checksums.txt + output: true # publish .sig + .pem as release assets + args: + - sign-blob + - "--output-signature=${signature}" + - "--output-certificate=${certificate}" + - "--yes" # non-interactive: skip the "this will be public" confirmation + - "${artifact}" # Linux packages via nfpm (.deb/.rpm). Homebrew tap added once the repo/org is # confirmed (Q-NAME). diff --git a/internal/cli/self.go b/internal/cli/self.go index c9cf538..61aac52 100644 --- a/internal/cli/self.go +++ b/internal/cli/self.go @@ -51,22 +51,25 @@ func newSelfCheckCmd(g *GlobalOpts) *cobra.Command { func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command { var ( - check bool - pin string - force bool + check bool + pin string + force bool + skipVerify bool ) cmd := &cobra.Command{ Use: "update", Short: "Download and install the latest release (refuses on package-managed installs)", Long: "update replaces this binary with the latest GitHub release for your OS/arch,\n" + - "verifying its SHA-256 checksum and replacing it atomically. Homebrew/dpkg/rpm\n" + - "installs are refused with the right upgrade command instead.", + "verifying its cosign keyless signature and SHA-256 checksum, then replacing it\n" + + "atomically. Signature verification requires the `cosign` binary and is ON by\n" + + "default; pass --insecure-skip-verify to bypass it (the SHA-256 checksum is still\n" + + "enforced). Homebrew/dpkg/rpm installs are refused with the right upgrade command.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if check { return newSelfCheckCmd(g).RunE(cmd, nil) } - res, err := selfupdate.Update(cmd.Context(), version.Version, selfupdate.Options{Version: pin, Force: force}) + res, err := selfupdate.Update(cmd.Context(), version.Version, selfupdate.Options{Version: pin, Force: force, SkipVerify: skipVerify}) if err != nil { return err } @@ -91,12 +94,17 @@ func newSelfUpdateCmd(g *GlobalOpts) *cobra.Command { return writeJSON(cmd, res) } fmt.Fprintf(w, "updated %s → %s\n", res.From, res.To) - fmt.Fprintln(w, "(verified by SHA-256 checksum; release-signature enforcement is not yet wired — spec 14)") + if res.Verified { + fmt.Fprintln(w, "(verified by cosign keyless signature + SHA-256 checksum)") + } else { + fmt.Fprintf(w, "(%s)\n", res.VerifyNote) + } return nil }, } cmd.Flags().BoolVar(&check, "check", false, "only check for a newer version; do not install") cmd.Flags().StringVar(&pin, "version", "", "install a specific release tag (e.g. v0.2.0)") cmd.Flags().BoolVar(&force, "force", false, "re-install even when already up to date (still refuses package-managed installs)") + cmd.Flags().BoolVar(&skipVerify, "insecure-skip-verify", false, "bypass cosign release-signature verification (SHA-256 checksum is still enforced)") return cmd } diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index 2f08a89..b41d102 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -94,21 +94,24 @@ func TestReplaceExecutableAtomic(t *testing.T) { } } -// releaseServer mimics the GitHub API release-by-tag + asset endpoints: the -// tags endpoint returns assets whose URLs point back at this server. +// releaseServer mimics the GitHub API release-by-tag + asset endpoints: the tags +// endpoint returns the archive, checksums.txt, and (keyless-signing) the +// checksums.txt.sig + checksums.txt.pem assets, all pointing back at this server. func releaseServer(t *testing.T, tag string, archive []byte, sum string) (*httptest.Server, func()) { t.Helper() asset := assetName(tag, runtime.GOOS, runtime.GOARCH) mux := http.NewServeMux() srv := httptest.NewServer(mux) mux.HandleFunc("/releases/tags/"+tag, func(w http.ResponseWriter, _ *http.Request) { - fmt.Fprintf(w, `{"assets":[{"name":%q,"url":%q},{"name":"checksums.txt","url":%q}]}`, - asset, srv.URL+"/asset/archive", srv.URL+"/asset/sums") + fmt.Fprintf(w, `{"assets":[{"name":%q,"url":%q},{"name":"checksums.txt","url":%q},{"name":"checksums.txt.sig","url":%q},{"name":"checksums.txt.pem","url":%q}]}`, + asset, srv.URL+"/asset/archive", srv.URL+"/asset/sums", srv.URL+"/asset/sig", srv.URL+"/asset/pem") }) mux.HandleFunc("/asset/archive", func(w http.ResponseWriter, _ *http.Request) { w.Write(archive) }) mux.HandleFunc("/asset/sums", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintf(w, "%s %s\n", sum, asset) }) + mux.HandleFunc("/asset/sig", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "FAKE-SIGNATURE") }) + mux.HandleFunc("/asset/pem", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprint(w, "FAKE-CERT") }) old := APIBase APIBase = srv.URL return srv, func() { APIBase = old; srv.Close() } @@ -119,7 +122,7 @@ func TestDownloadBinaryVerifies(t *testing.T) { _, cleanup := releaseServer(t, "v0.2.0", archive, sum) defer cleanup() - bin, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH) + bin, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH, okVerifier{}, false) if err != nil { t.Fatal(err) } @@ -133,7 +136,7 @@ func TestDownloadBinaryChecksumMismatchAborts(t *testing.T) { _, cleanup := releaseServer(t, "v0.2.0", archive, "0000000000000000000000000000000000000000000000000000000000000000") defer cleanup() - if _, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH); err == nil { + if _, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH, okVerifier{}, false); err == nil { t.Fatal("want a checksum-mismatch error, got nil") } } diff --git a/internal/selfupdate/update.go b/internal/selfupdate/update.go index 0912701..7675782 100644 --- a/internal/selfupdate/update.go +++ b/internal/selfupdate/update.go @@ -22,6 +22,15 @@ type Options struct { Version string // pin a release tag (e.g. "v0.2.0"); "" = latest Force bool // re-install even when already up-to-date (repair a corrupt binary); // NEVER overrides the package-manager CanSelfReplace refusal (spec 26/14). + + // SkipVerify bypasses cosign release-signature verification (the + // --insecure-skip-verify escape hatch). Verification is ON by default; + // SkipVerify only disables the cosign check — the SHA-256 checksum is ALWAYS + // enforced, so this never silently drops to "no integrity check" (spec 14). + SkipVerify bool + // Verifier injects the signature backend; nil uses the cosign-shelling default. + // Tests set a fake to run without cosign or the network. + Verifier Verifier } // Result reports what Update did (or why it refused). @@ -31,6 +40,11 @@ type Result struct { Replaced bool UpToDate bool Install Install + // Verified is true when the release's cosign keyless signature was verified. + Verified bool + // VerifyNote explains the verification outcome for the human-facing summary + // (e.g. why signature verification was skipped). + VerifyNote string } // Update performs the self-update flow for the running binary. When the install @@ -64,10 +78,15 @@ func Update(ctx context.Context, current string, opts Options) (*Result, error) return res, nil } - bin, err := downloadBinary(ctx, tag, runtime.GOOS, runtime.GOARCH) + bin, err := downloadBinary(ctx, tag, runtime.GOOS, runtime.GOARCH, opts.Verifier, opts.SkipVerify) if err != nil { return res, err } + if opts.SkipVerify { + res.VerifyNote = "cosign signature verification skipped (--insecure-skip-verify); integrity checked by SHA-256 only" + } else { + res.Verified = true + } if err := replaceExecutable(inst.Path, bin); err != nil { return res, err } @@ -96,10 +115,14 @@ func assetName(tag, goos, goarch string) string { return fmt.Sprintf("%s_%s_%s_%s.tar.gz", Binary, strings.TrimPrefix(tag, "v"), goos, goarch) } -// downloadBinary fetches the release archive, verifies its SHA-256 against the -// release checksums.txt, and returns the extracted binary bytes. Assets are -// fetched through the GitHub API (token-honoring; works for private repos). -func downloadBinary(ctx context.Context, tag, goos, goarch string) ([]byte, error) { +// downloadBinary fetches the release archive and returns the extracted binary +// bytes. The integrity chain is: (1) unless skipVerify, verify the cosign keyless +// signature over checksums.txt (the trust root — v.VerifyBlob against the published +// .sig/.pem, pinned to this repo's release-workflow identity); (2) verify the +// archive's SHA-256 against the now-trusted checksums.txt. SHA-256 is enforced +// regardless of skipVerify. Assets are fetched through the GitHub API +// (token-honoring; works for private repos). +func downloadBinary(ctx context.Context, tag, goos, goarch string, v Verifier, skipVerify bool) ([]byte, error) { assetFile := assetName(tag, goos, goarch) assets, err := releaseAssets(ctx, tag) if err != nil { @@ -123,6 +146,13 @@ func downloadBinary(ctx context.Context, tag, goos, goarch string) ([]byte, erro return nil, fmt.Errorf("download checksums.txt: %w", err) } + // Verify the signature over checksums.txt BEFORE trusting any hash in it. + if !skipVerify { + if err := verifyChecksumsSignature(ctx, v, assets, sums); err != nil { + return nil, err + } + } + want := checksumFor(string(sums), assetFile) if want == "" { return nil, fmt.Errorf("no checksum entry for %s in this release", assetFile) diff --git a/internal/selfupdate/verify.go b/internal/selfupdate/verify.go new file mode 100644 index 0000000..d360d5e --- /dev/null +++ b/internal/selfupdate/verify.go @@ -0,0 +1,141 @@ +package selfupdate + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// This file adds cosign signature verification to the self-update flow (spec 14 § +// "Signature verification is mandatory", spec 25). Releases are signed KEYLESS via +// GitHub OIDC (sigstore/cosign) in .github/workflows/release.yml: goreleaser's +// `signs:` block runs `cosign sign-blob` over checksums.txt and publishes +// checksums.txt.sig (signature) + checksums.txt.pem (Fulcio certificate) as release +// assets. checksums.txt is the ROOT of the integrity chain — once its signature is +// verified, the per-archive SHA-256 lines in it are trusted, and downloadBinary's +// existing SHA-256 check ties each archive back to that trusted list. +// +// Verification shells the external `cosign` binary behind the Verifier interface +// (mock-able, like internal/docker / internal/git), so tests never touch the +// network or need cosign installed. Pure-Go verification via sigstore-go was +// considered but shelling cosign keeps the release binary CGO-free and matches the +// repo's "wrap the external tool behind an internal/ interface" rule. + +const ( + // CertIdentityRegexp pins the signer identity to THIS repo's release workflow + // (the SAN of the Fulcio cert cosign issues to the GitHub Actions job). Keyless + // signing embeds the workflow ref, e.g. + // https://github.com/open-source-cloud/devstack/.github/workflows/release.yml@refs/tags/v0.2.0 + // so we anchor on the workflow path and accept any ref suffix. + CertIdentityRegexp = `^https://github\.com/open-source-cloud/devstack/\.github/workflows/release\.yml@.+$` + + // OIDCIssuer is the GitHub Actions OIDC token issuer — the only issuer we trust + // for keyless release signatures. + OIDCIssuer = "https://token.actions.githubusercontent.com" + + // cosignBin is the external binary shelled for verification. + cosignBin = "cosign" + + // sigAssetName / certAssetName are the goreleaser-published signature + Fulcio + // certificate for checksums.txt. + sigAssetName = "checksums.txt.sig" + certAssetName = "checksums.txt.pem" +) + +// Verifier verifies a keyless cosign signature over a blob. Behind an interface so +// tests inject a fake and the real implementation shells cosign. +type Verifier interface { + // Available reports whether the verification backend (the cosign binary) is + // usable on this host. When false, the caller decides whether to abort with a + // remediation or honor an explicit --insecure-skip-verify. + Available() bool + // VerifyBlob returns nil when sig is a valid keyless signature over blob, made + // by cert, whose identity matches CertIdentityRegexp and whose OIDC issuer is + // OIDCIssuer. Any other outcome (bad signature, wrong identity, tampered blob) + // returns a non-nil error. + VerifyBlob(ctx context.Context, blob, sig, cert []byte) error +} + +// defaultVerifier is the cosign-backed Verifier used when Options.Verifier is nil. +var defaultVerifier Verifier = cosignVerifier{} + +// cosignVerifier shells `cosign verify-blob` (keyless). +type cosignVerifier struct{} + +// Available reports whether the cosign binary is on PATH. +func (cosignVerifier) Available() bool { + _, err := exec.LookPath(cosignBin) + return err == nil +} + +// VerifyBlob writes blob/sig/cert to temp files and runs `cosign verify-blob`, +// pinning the certificate identity + OIDC issuer to this repo's release workflow. +func (cosignVerifier) VerifyBlob(ctx context.Context, blob, sig, cert []byte) error { + dir, err := os.MkdirTemp("", "devstack-cosign-") + if err != nil { + return fmt.Errorf("create cosign scratch dir: %w", err) + } + defer os.RemoveAll(dir) + + blobPath := filepath.Join(dir, "checksums.txt") + sigPath := filepath.Join(dir, sigAssetName) + certPath := filepath.Join(dir, certAssetName) + for path, data := range map[string][]byte{blobPath: blob, sigPath: sig, certPath: cert} { + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("write cosign input %s: %w", filepath.Base(path), err) + } + } + + // #nosec G204 -- args are fixed constants + tool-controlled temp paths. + cmd := exec.CommandContext(ctx, cosignBin, + "verify-blob", + "--certificate-identity-regexp", CertIdentityRegexp, + "--certificate-oidc-issuer", OIDCIssuer, + "--signature", sigPath, + "--certificate", certPath, + blobPath, + ) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("cosign signature verification failed for checksums.txt — refusing to install (release may be unsigned, tampered, or signed by an untrusted identity): %w: %s", + err, strings.TrimSpace(string(out))) + } + return nil +} + +// verifyChecksumsSignature verifies the cosign keyless signature over the raw +// checksums.txt bytes, fetching the .sig + .pem assets from the same release. It is +// the mandatory integrity gate before any archive SHA-256 is trusted. A missing +// verifier binary or missing signature asset aborts (unless the caller passed +// --insecure-skip-verify) with a clear remediation — it never silently downgrades. +func verifyChecksumsSignature(ctx context.Context, v Verifier, assets map[string]asset, sums []byte) error { + if v == nil { + v = defaultVerifier + } + if !v.Available() { + return fmt.Errorf("cosign not found: release-signature verification is required but the `cosign` binary is not installed.\n" + + " Install it (https://docs.sigstore.dev/system_config/installation/) and retry,\n" + + " or re-run with --insecure-skip-verify to bypass signature checks (SHA-256 checksum is still enforced).") + } + sigAsset, ok := assets[sigAssetName] + if !ok { + return fmt.Errorf("release has no %s — refusing to install an unsigned release (pass --insecure-skip-verify to override; SHA-256 still enforced)", sigAssetName) + } + certAsset, ok := assets[certAssetName] + if !ok { + return fmt.Errorf("release has no %s — refusing to install without a signing certificate (pass --insecure-skip-verify to override; SHA-256 still enforced)", certAssetName) + } + + sig, err := downloadAsset(ctx, sigAsset.URL) + if err != nil { + return fmt.Errorf("download %s: %w", sigAssetName, err) + } + cert, err := downloadAsset(ctx, certAsset.URL) + if err != nil { + return fmt.Errorf("download %s: %w", certAssetName, err) + } + return v.VerifyBlob(ctx, sums, sig, cert) +} diff --git a/internal/selfupdate/verify_test.go b/internal/selfupdate/verify_test.go new file mode 100644 index 0000000..060aea5 --- /dev/null +++ b/internal/selfupdate/verify_test.go @@ -0,0 +1,203 @@ +package selfupdate + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + yaml "github.com/goccy/go-yaml" +) + +// okVerifier is a Verifier that is always available and always accepts — the happy +// path stand-in so download tests never shell real cosign or hit the network. +type okVerifier struct{} + +func (okVerifier) Available() bool { return true } +func (okVerifier) VerifyBlob(context.Context, []byte, []byte, []byte) error { return nil } + +// recordingVerifier captures its inputs and returns a configurable availability + +// verification result, so a test can assert what was verified and simulate tamper. +type recordingVerifier struct { + available bool + err error + called bool + gotBlob []byte + gotSig []byte + gotCert []byte +} + +func (r *recordingVerifier) Available() bool { return r.available } +func (r *recordingVerifier) VerifyBlob(_ context.Context, blob, sig, cert []byte) error { + r.called = true + r.gotBlob, r.gotSig, r.gotCert = blob, sig, cert + return r.err +} + +// TestSignatureVerifyHappyPath: a good signature over checksums.txt lets the +// download proceed, and the verifier is handed the exact checksums.txt bytes + +// published .sig/.pem. +func TestSignatureVerifyHappyPath(t *testing.T) { + archive, sum := makeArchive(t, "SIGNED-BINARY") + _, cleanup := releaseServer(t, "v0.2.0", archive, sum) + defer cleanup() + + v := &recordingVerifier{available: true} + bin, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH, v, false) + if err != nil { + t.Fatal(err) + } + if string(bin) != "SIGNED-BINARY" { + t.Errorf("downloaded %q", bin) + } + if !v.called { + t.Fatal("verifier was never invoked — signature check was skipped") + } + if !strings.Contains(string(v.gotBlob), sum) { + t.Errorf("verifier got blob %q, want it to contain the checksum %q", v.gotBlob, sum) + } + if string(v.gotSig) != "FAKE-SIGNATURE" || string(v.gotCert) != "FAKE-CERT" { + t.Errorf("verifier got sig=%q cert=%q, want the published .sig/.pem", v.gotSig, v.gotCert) + } +} + +// TestSignatureVerifyTamperDetected: a verifier that rejects the signature aborts +// the update, leaving the download unusable (tamper/unsigned/wrong-identity). +func TestSignatureVerifyTamperDetected(t *testing.T) { + archive, sum := makeArchive(t, "TAMPERED-BINARY") + _, cleanup := releaseServer(t, "v0.2.0", archive, sum) + defer cleanup() + + v := &recordingVerifier{available: true, err: context.Canceled} // any non-nil error + if _, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH, v, false); err == nil { + t.Fatal("want a signature-verification error, got nil") + } +} + +// TestSignatureVerifyRunsBeforeChecksum: signature verification is the trust root — +// it must run before (and independently of) the per-archive SHA-256 check. +func TestSignatureVerifyRunsBeforeChecksum(t *testing.T) { + archive, _ := makeArchive(t, "X") + // Wrong checksum in the list, but the signature verifier rejects first. + _, cleanup := releaseServer(t, "v0.2.0", archive, "deadbeef") + defer cleanup() + + v := &recordingVerifier{available: true, err: context.Canceled} + _, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH, v, false) + if err == nil || !v.called { + t.Fatalf("signature verifier should have run and failed first (called=%v err=%v)", v.called, err) + } +} + +// TestCosignUnavailableAborts: with verification on (default) and no cosign binary, +// the update refuses with a remediation rather than silently proceeding. +func TestCosignUnavailableAborts(t *testing.T) { + archive, sum := makeArchive(t, "Y") + _, cleanup := releaseServer(t, "v0.2.0", archive, sum) + defer cleanup() + + v := &recordingVerifier{available: false} + _, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH, v, false) + if err == nil { + t.Fatal("want an abort when cosign is unavailable, got nil") + } + if !strings.Contains(err.Error(), "cosign") || !strings.Contains(err.Error(), "insecure-skip-verify") { + t.Errorf("error should name cosign + the escape hatch, got: %v", err) + } + if v.called { + t.Error("VerifyBlob must not be called when the backend is unavailable") + } +} + +// TestMissingSignatureAssetAborts: a release without a checksums.txt.sig is +// refused (an unsigned release) when verification is on. +func TestMissingSignatureAssetAborts(t *testing.T) { + archive, sum := makeArchive(t, "Z") + _, cleanup := unsignedReleaseServer(t, "v0.2.0", archive, sum) + defer cleanup() + + v := &recordingVerifier{available: true} + _, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH, v, false) + if err == nil { + t.Fatal("want an abort for a release with no signature asset, got nil") + } + if !strings.Contains(err.Error(), sigAssetName) { + t.Errorf("error should mention the missing %s, got: %v", sigAssetName, err) + } +} + +// TestSkipVerifyBypassesSignature: --insecure-skip-verify skips the cosign check +// entirely (no verifier call, no .sig needed) but still enforces SHA-256. +func TestSkipVerifyBypassesSignature(t *testing.T) { + archive, sum := makeArchive(t, "UNSIGNED-BUT-OK") + _, cleanup := unsignedReleaseServer(t, "v0.2.0", archive, sum) + defer cleanup() + + v := &recordingVerifier{available: true} + bin, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH, v, true) + if err != nil { + t.Fatal(err) + } + if string(bin) != "UNSIGNED-BUT-OK" { + t.Errorf("downloaded %q", bin) + } + if v.called { + t.Error("--insecure-skip-verify must not invoke the verifier") + } + + // SHA-256 is STILL enforced even with signature verification skipped. + badArchive, _ := makeArchive(t, "SOMETHING-ELSE") + _, cleanup2 := unsignedReleaseServer(t, "v0.2.0", badArchive, "deadbeef") + defer cleanup2() + if _, err := downloadBinary(context.Background(), "v0.2.0", runtime.GOOS, runtime.GOARCH, v, true); err == nil { + t.Fatal("SHA-256 must still fail a tampered archive even with --insecure-skip-verify") + } +} + +// unsignedReleaseServer serves a release WITHOUT the .sig/.pem assets. +func unsignedReleaseServer(t *testing.T, tag string, archive []byte, sum string) (*httptest.Server, func()) { + t.Helper() + asset := assetName(tag, runtime.GOOS, runtime.GOARCH) + mux := http.NewServeMux() + srv := httptest.NewServer(mux) + mux.HandleFunc("/releases/tags/"+tag, func(w http.ResponseWriter, _ *http.Request) { + fmt.Fprintf(w, `{"assets":[{"name":%q,"url":%q},{"name":"checksums.txt","url":%q}]}`, + asset, srv.URL+"/asset/archive", srv.URL+"/asset/sums") + }) + mux.HandleFunc("/asset/archive", func(w http.ResponseWriter, _ *http.Request) { w.Write(archive) }) + mux.HandleFunc("/asset/sums", func(w http.ResponseWriter, _ *http.Request) { fmt.Fprintf(w, "%s %s\n", sum, asset) }) + old := APIBase + APIBase = srv.URL + return srv, func() { APIBase = old; srv.Close() } +} + +// TestReleaseWorkflowDeclaresIDToken asserts the release workflow YAML parses and +// grants id-token: write (required for keyless cosign OIDC signing). No network. +func TestReleaseWorkflowDeclaresIDToken(t *testing.T) { + _, thisFile, _, _ := runtime.Caller(0) + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) + data, err := os.ReadFile(filepath.Join(repoRoot, ".github", "workflows", "release.yml")) + if err != nil { + t.Fatalf("read release.yml: %v", err) + } + var wf struct { + Permissions map[string]string `yaml:"permissions"` + } + if err := yaml.Unmarshal(data, &wf); err != nil { + t.Fatalf("release.yml is not valid YAML: %v", err) + } + if got := wf.Permissions["id-token"]; got != "write" { + t.Errorf("release.yml permissions.id-token = %q, want %q (keyless cosign needs it)", got, "write") + } + if got := wf.Permissions["contents"]; got != "write" { + t.Errorf("release.yml permissions.contents = %q, want %q", got, "write") + } + if !strings.Contains(string(data), "cosign-installer") { + t.Error("release.yml must install cosign (sigstore/cosign-installer) for signing") + } +} From 1df1203423bf743152cfdb123ed0aa65fce5aea9 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Wed, 1 Jul 2026 00:30:01 -0300 Subject: [PATCH 2/2] ci(dryrun): skip signing in the release dry-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dry-run runs 'goreleaser release --snapshot', which now hits the new cosign signs: block and fails with 'cosign: executable file not found' — keyless OIDC signing can't run in a PR dry-run. Skip the sign pipe there; the real tagged release (release.yml) installs cosign and signs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e823375..5174021 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,7 +101,10 @@ jobs: - uses: goreleaser/goreleaser-action@v6 with: version: "~> v2" - args: release --snapshot --clean + # --skip=sign: the dry-run validates build/archive/package config only. + # Signing is keyless cosign over GitHub OIDC (release.yml) and cannot run + # in a PR dry-run (no cosign binary, no id-token) — the real release signs. + args: release --snapshot --clean --skip=sign # Native macOS arm64 lane (G2): proves the darwin/arm64 RUNTIME target — not just # the cross-compile on the Linux lane — actually builds and passes its daemon-free