banger/internal/updater/verify_signature.go
Thales Maciel b7c9661c99
updater: embed real cosign public key for v0.1.0 release signing
The placeholder in BangerReleasePublicKey is replaced with the
production cosign public key (P-256 ECDSA). The matching private
key is stored offline by the maintainer; this is the public half
that every banger CLI baked from this commit forward will use to
verify SHA256SUMS signatures.

cosign.pub is also committed at the repo root so external auditors
can re-verify a release without parsing the Go source.

The placeholder-refuses test now swaps the embedded key for a
synthetic placeholder for the duration of the test, since the
default value is no longer a placeholder.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 12:50:52 -03:00

130 lines
5.2 KiB
Go

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-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAElWFSLKLosBrdjfuF8ZS6U01Ufky4
zNeVPCkA6HEJ/oe634fRqwFxkXKGWg03eGFSnlwRxnUxN2+duXQSsR0pzQ==
-----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")
}