updater: cosign-blob signature verification on SHA256SUMS

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) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-29 12:37:53 -03:00
parent 92ca1aa96f
commit 8ed351ea47
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 257 additions and 1 deletions

View file

@ -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

View file

@ -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")
}

View file

@ -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)
}
})
}
}