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