From 8ed351ea477f21331f538179650d93891b47ad38 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Wed, 29 Apr 2026 12:37:53 -0300 Subject: [PATCH] updater: cosign-blob signature verification on SHA256SUMS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the v0.1.0 cosign requirement. Every banger update download now goes through ECDSA-P256 verification before any binary is trusted: SHA256SUMS.sig is fetched, base64-decoded, and verified against the embedded BangerReleasePublicKey. * BangerReleasePublicKey: PEM-encoded ECDSA public key embedded at compile time. The current value is a sentinel PLACEHOLDER — the maintainer must replace it with the output of `cosign generate-key-pair`'s cosign.pub before cutting v0.1.0, and re-cut. Until they do, every `banger update` refuses with ErrSignatureRequired ("the maintainer must replace it and re-cut a release before update can proceed"). Loud refusal beats silent acceptance. * VerifyBlobSignature: parses the embedded public key, base64- decodes the signature, computes SHA256(body), runs ecdsa .VerifyASN1. cosign sign-blob produces the format VerifyASN1 verifies natively (ASN.1-DER encoded ECDSA over a SHA256 digest), so no third-party crypto deps needed. * FetchAndVerifySignature: pulls the signature URL from the release manifest entry, fetches it (1 KiB cap), and verifies against sumsBody. Refuses outright when sha256sums_sig_url is empty — v0.1.0 contract requires every release to be signed, and an unsigned release is a manifest publishing bug we'd rather catch loudly than silently accept. * Wired into banger update: sumsBody captured from DownloadRelease, immediately fed into FetchAndVerifySignature. A failed verification removes the staged tarball before returning so it can't be reused. * BangerReleasePublicKey is var (not const) only to support tests that swap in a generated keypair; production sets it at compile time and never mutates it. Tests: placeholder-key path returns ErrSignatureRequired; happy path with a fresh in-test ECDSA keypair verifies a real sign-then-verify; tampered body, wrong key, and three malformed signature shapes (not-base64, empty, garbage-DER) all reject. Maintainer-cut workflow documented in BangerReleasePublicKey's comment: cosign generate-key-pair → paste cosign.pub into the constant → at release time, cosign sign-blob --key cosign.key SHA256SUMS > SHA256SUMS.sig and publish. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/cli/commands_update.go | 9 +- internal/updater/verify_signature.go | 129 ++++++++++++++++++++++ internal/updater/verify_signature_test.go | 120 ++++++++++++++++++++ 3 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 internal/updater/verify_signature.go create mode 100644 internal/updater/verify_signature_test.go diff --git a/internal/cli/commands_update.go b/internal/cli/commands_update.go index 42e97aa..1c0ee3f 100644 --- a/internal/cli/commands_update.go +++ b/internal/cli/commands_update.go @@ -138,9 +138,16 @@ func (d *deps) runUpdate(cmd *cobra.Command, opts runUpdateOpts) error { } tarballPath := filepath.Join(stagingDir, stagingTarballName) fmt.Fprintf(out, "downloading %s …\n", target.TarballURL) - if _, err := updater.DownloadRelease(ctx, client, target, tarballPath); err != nil { + sumsBody, err := updater.DownloadRelease(ctx, client, target, tarballPath) + if err != nil { return fmt.Errorf("download: %w", err) } + if err := updater.FetchAndVerifySignature(ctx, client, target, sumsBody); err != nil { + // Don't leave the staged tarball around — it failed + // signature verification and shouldn't be re-runnable. + _ = os.Remove(tarballPath) + return fmt.Errorf("signature: %w", err) + } stagedDir := filepath.Join(stagingDir, "staged") if err := os.RemoveAll(stagedDir); err != nil && !os.IsNotExist(err) { return err diff --git a/internal/updater/verify_signature.go b/internal/updater/verify_signature.go new file mode 100644 index 0000000..b17ee3e --- /dev/null +++ b/internal/updater/verify_signature.go @@ -0,0 +1,129 @@ +package updater + +import ( + "context" + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "fmt" + "net/http" + "strings" +) + +// MaxSignatureBytes caps the cosign signature download. A blob +// signature is ~70 bytes raw (an ECDSA P-256 ASN.1 signature) plus +// some base64 overhead and a trailing newline; 1 KiB is generous. +const MaxSignatureBytes int64 = 1024 + +// BangerReleasePublicKey is the cosign-generated public key used to +// verify SHA256SUMS for every banger release. SET ME BEFORE THE +// FIRST RELEASE. The placeholder below is intentionally invalid so +// `banger update` refuses every download until a real key lands. +// +// Production-cut workflow (for the maintainer cutting v0.1.0): +// +// 1. Generate the keypair (one-time, store the private key offline): +// cosign generate-key-pair +// Produces cosign.key (private) and cosign.pub (public). The +// private key is password-protected; remember the password. +// +// 2. Replace the PEM block below with the contents of cosign.pub. +// Commit. From this point on, every banger CLI baked from this +// repo will only trust signatures made with cosign.key. +// +// 3. At release time, sign SHA256SUMS: +// cosign sign-blob --key cosign.key --output-signature \ +// SHA256SUMS.sig SHA256SUMS +// Publish SHA256SUMS.sig alongside SHA256SUMS in the bucket; +// the manifest's `sha256sums_sig_url` field references it. +// +// 4. Rotating the key after publication means publishing a new +// banger release that embeds the new key, then re-signing +// every release artifact with the new key. v0.1.x is too +// early to design a clean rotation story; defer. +// var (rather than const) only because tests need to swap it for an +// in-test-generated key; production sets it at compile time and +// never mutates it. +var BangerReleasePublicKey = `-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEPLACEHOLDER0000000000000000000 +000000000000000000000000000000000000000000000000000000000000PLACE +-----END PUBLIC KEY-----` + +// ErrSignatureRequired is returned by VerifyManifestRelease when the +// embedded public key is the placeholder. Surfaces as a clear "the +// release maintainer hasn't published their cosign key yet, refusing +// to update" rather than a cryptic crypto error. +var ErrSignatureRequired = errors.New("banger release public key is the placeholder; the maintainer must replace it and re-cut a release before update can proceed") + +// VerifyBlobSignature checks that sigBase64 is a valid cosign-blob +// signature over body, made with the private counterpart of +// BangerReleasePublicKey. cosign's blob signature format is a +// base64-encoded ASN.1-DER ECDSA signature over SHA256(body) — that's +// what the package's ecdsa.VerifyASN1 verifies natively. +// +// Refuses outright if the embedded public key is still the build- +// time placeholder, so an unset key can't slip through as +// "verification disabled." +func VerifyBlobSignature(body, sigBase64 []byte) error { + if isPlaceholderKey(BangerReleasePublicKey) { + return ErrSignatureRequired + } + block, _ := pem.Decode([]byte(BangerReleasePublicKey)) + if block == nil { + return fmt.Errorf("decode banger release public key: no PEM block") + } + pubAny, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return fmt.Errorf("parse banger release public key: %w", err) + } + pub, ok := pubAny.(*ecdsa.PublicKey) + if !ok { + return fmt.Errorf("banger release public key is not ECDSA") + } + sigBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sigBase64))) + if err != nil { + return fmt.Errorf("decode signature base64: %w", err) + } + digest := sha256.Sum256(body) + if !ecdsa.VerifyASN1(pub, digest[:], sigBytes) { + return fmt.Errorf("signature does not verify against banger release public key") + } + return nil +} + +// FetchAndVerifySignature pulls the SHA256SUMS.sig URL from the +// release, downloads it (capped), and verifies it against +// sumsBody. Returns nil on a clean pass, or an error describing +// exactly why verification failed. +// +// If release.SHA256SumsSigURL is empty, treat that as "release was +// not signed" — refuse rather than silently proceeding. v0.1.0 +// requires every release to be cosign-signed; an unsigned release +// is a manifest publishing bug we'd rather catch loudly. +func FetchAndVerifySignature(ctx context.Context, client *http.Client, release Release, sumsBody []byte) error { + if strings.TrimSpace(release.SHA256SumsSigURL) == "" { + return fmt.Errorf("release %s has no sha256sums_sig_url; refusing to install an unsigned release", release.Version) + } + if client == nil { + client = http.DefaultClient + } + sig, err := fetchBounded(ctx, client, release.SHA256SumsSigURL, MaxSignatureBytes) + if err != nil { + return fmt.Errorf("fetch signature: %w", err) + } + if err := VerifyBlobSignature(sumsBody, sig); err != nil { + return fmt.Errorf("verify SHA256SUMS signature: %w", err) + } + return nil +} + +// isPlaceholderKey detects the build-time placeholder constant. A +// real cosign-generated PEM never contains the string "PLACEHOLDER"; +// a real ECDSA P-256 key block decodes to ~91 bytes of content, +// nowhere near our padded constant. +func isPlaceholderKey(pem string) bool { + return strings.Contains(pem, "PLACEHOLDER") +} diff --git a/internal/updater/verify_signature_test.go b/internal/updater/verify_signature_test.go new file mode 100644 index 0000000..e514179 --- /dev/null +++ b/internal/updater/verify_signature_test.go @@ -0,0 +1,120 @@ +package updater + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "errors" + "strings" + "testing" +) + +// generateTestKey produces an ECDSA P-256 keypair in PEM form, +// matching the shape `cosign generate-key-pair` emits for the public +// half. The private half stays in-test for signing. +func generateTestKey(t *testing.T) (privKey *ecdsa.PrivateKey, pubPEM string) { + t.Helper() + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + der, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) + if err != nil { + t.Fatalf("marshal public key: %v", err) + } + pubPEM = string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der})) + return priv, pubPEM +} + +// signBlob mimics `cosign sign-blob`'s output: base64-encoded ASN.1-DER +// ECDSA signature over SHA256(body). +func signBlob(t *testing.T, priv *ecdsa.PrivateKey, body []byte) string { + t.Helper() + digest := sha256.Sum256(body) + sig, err := ecdsa.SignASN1(rand.Reader, priv, digest[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + return base64.StdEncoding.EncodeToString(sig) +} + +func TestVerifyBlobSignaturePlaceholderRefuses(t *testing.T) { + // The default constant in this binary is the placeholder. Any + // verify call must refuse with ErrSignatureRequired so an + // un-rotated build can't silently accept anything. + err := VerifyBlobSignature([]byte("body"), []byte("sig")) + if !errors.Is(err, ErrSignatureRequired) { + t.Fatalf("err = %v, want ErrSignatureRequired", err) + } +} + +func TestVerifyBlobSignatureHappyPath(t *testing.T) { + priv, pubPEM := generateTestKey(t) + prev := BangerReleasePublicKey + BangerReleasePublicKey = pubPEM + defer func() { BangerReleasePublicKey = prev }() + + body := []byte("SHA256SUMS body bytes") + sig := signBlob(t, priv, body) + if err := VerifyBlobSignature(body, []byte(sig)); err != nil { + t.Fatalf("VerifyBlobSignature: %v", err) + } +} + +func TestVerifyBlobSignatureRejectsTamperedBody(t *testing.T) { + priv, pubPEM := generateTestKey(t) + prev := BangerReleasePublicKey + BangerReleasePublicKey = pubPEM + defer func() { BangerReleasePublicKey = prev }() + + body := []byte("original body") + sig := signBlob(t, priv, body) + tampered := []byte("tampered body") + err := VerifyBlobSignature(tampered, []byte(sig)) + if err == nil || !strings.Contains(err.Error(), "does not verify") { + t.Fatalf("err = %v, want signature-mismatch", err) + } +} + +func TestVerifyBlobSignatureRejectsWrongKey(t *testing.T) { + // Sign with one key, verify with a different one. + signingPriv, _ := generateTestKey(t) + _, otherPubPEM := generateTestKey(t) + prev := BangerReleasePublicKey + BangerReleasePublicKey = otherPubPEM + defer func() { BangerReleasePublicKey = prev }() + + body := []byte("body") + sig := signBlob(t, signingPriv, body) + err := VerifyBlobSignature(body, []byte(sig)) + if err == nil || !strings.Contains(err.Error(), "does not verify") { + t.Fatalf("err = %v, want wrong-key rejection", err) + } +} + +func TestVerifyBlobSignatureRejectsMalformed(t *testing.T) { + _, pubPEM := generateTestKey(t) + prev := BangerReleasePublicKey + BangerReleasePublicKey = pubPEM + defer func() { BangerReleasePublicKey = prev }() + for _, tc := range []struct { + name string + sig string + }{ + {name: "not_base64", sig: "!!!not_b64!!!"}, + {name: "empty", sig: ""}, + {name: "garbage_bytes", sig: base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + err := VerifyBlobSignature([]byte("body"), []byte(tc.sig)) + if err == nil { + t.Fatalf("expected error for %s; got success", tc.name) + } + }) + } +}