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>
120 lines
3.6 KiB
Go
120 lines
3.6 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|