Skip to content
Merged
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
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -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)
Expand Down
27 changes: 26 additions & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
22 changes: 15 additions & 7 deletions internal/cli/self.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
15 changes: 9 additions & 6 deletions internal/selfupdate/selfupdate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand All @@ -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)
}
Expand All @@ -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")
}
}
Expand Down
40 changes: 35 additions & 5 deletions internal/selfupdate/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand Down
141 changes: 141 additions & 0 deletions internal/selfupdate/verify.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading
Loading