diff --git a/container/verifier/bundles.go b/container/verifier/bundles.go index ce61a43..ef2da82 100644 --- a/container/verifier/bundles.go +++ b/container/verifier/bundles.go @@ -26,8 +26,9 @@ import ( const DigestAlgorithmSHA256 = "sha256" // ErrNoBundles is returned by RetrieveBundles when the artifact carries no -// Sigstore signature or attestation in any supported layout — i.e. the -// artifact is unsigned as far as this package can tell. +// Sigstore signature or attestation in any supported layout — keyless +// (certificate-bearing), key-signed ("cosign sign --key"), or attestation — +// i.e. the artifact is unsigned as far as this package can tell. var ErrNoBundles = errors.New("no sigstore bundles found for artifact") // ErrVerificationFailed wraps every cryptographic verification failure @@ -38,7 +39,11 @@ var ErrVerificationFailed = errors.New("sigstore bundle verification failed") // Bundle is a Sigstore bundle retrieved for an artifact, in both parsed and // serialized form. Raw is the canonical JSON encoding, suitable for durable -// storage and later re-verification with VerifyBundleOffline. +// storage and later re-verification — with VerifyBundleOffline for keyless +// (certificate-bearing) bundles, or VerifyBundleOfflineWithKey for bundles +// reconstructed from the key-signed cosign layout (HasCertificate tells the +// two apart; key-signed bundles carry a "cosign-keypair" public-key hint +// instead of a certificate). type Bundle struct { // Parsed is the decoded bundle. Parsed *bundle.Bundle @@ -51,6 +56,18 @@ type Bundle struct { DigestHex string } +// HasCertificate reports whether the bundle carries a signing certificate — +// i.e. it came from a keyless (Fulcio) flow and verifies with VerifyBundle; +// a false result is the key-signed layout, verifying with +// VerifyBundleWithKey / VerifyBundleOfflineWithKey. +func (b Bundle) HasCertificate() bool { + if b.Parsed == nil { + return false + } + vm := b.Parsed.GetVerificationMaterial() + return vm.GetCertificate() != nil || vm.GetX509CertificateChain() != nil +} + // Identity is the signer identity extracted from a verified Sigstore bundle. type Identity struct { // SignerIdentity is the certificate's subject identity. For @@ -289,6 +306,32 @@ func VerifyBundleOffline( }, tm, expected, opts...) } +// VerifyBundleOfflineWithKey re-verifies a stored key-signed bundle (the +// Raw form of a bundle whose HasCertificate is false) against the artifact +// digest ("sha256:") and the given PEM public key. Key verification +// needs no trust root or network in the first place; this entry point only +// adds the parse step for stored bundles. +func VerifyBundleOfflineWithKey( + rawBundle []byte, + artifactDigest string, + pubKeyPEM []byte, +) (*verify.VerificationResult, error) { + digestAlgo, digestHex, ok := strings.Cut(artifactDigest, ":") + if !ok || digestAlgo == "" || digestHex == "" { + return nil, fmt.Errorf("artifact digest %q is not in : form", artifactDigest) + } + parsed := &bundle.Bundle{} + if err := parsed.UnmarshalJSON(rawBundle); err != nil { + return nil, fmt.Errorf("parsing stored bundle: %w", err) + } + return VerifyBundleWithKey(Bundle{ + Parsed: parsed, + Raw: rawBundle, + DigestAlgo: digestAlgo, + DigestHex: digestHex, + }, pubKeyPEM) +} + // identityPolicyOption translates an expected Identity into a Sigstore // certificate-identity policy. For identities recorded from GitHub Actions // certificates the SAN is the repository URI + workflow path (+ "@ref"), so diff --git a/container/verifier/doc.go b/container/verifier/doc.go index 32ee8f6..a14ab0e 100644 --- a/container/verifier/doc.go +++ b/container/verifier/doc.go @@ -29,6 +29,11 @@ // // store bundles[0].Raw and identity; later: // _, err = verifier.VerifyBundleOffline(storedRaw, "sha256:"+digestHex, &identity) // +// Key-signed bundles (Bundle.HasCertificate() == false, the "cosign sign +// --key" layout) carry no certificate identity; verify them with +// VerifyBundleWithKey at retrieval time and VerifyBundleOfflineWithKey for +// stored Raw bytes — both take the signer's PEM public key. +// // # Stability // // This package is Alpha stability. The API may change without notice. diff --git a/container/verifier/keysigned_test.go b/container/verifier/keysigned_test.go new file mode 100644 index 0000000..2961c8e --- /dev/null +++ b/container/verifier/keysigned_test.go @@ -0,0 +1,179 @@ +// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +// SPDX-License-Identifier: Apache-2.0 + +package verifier + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "net/http/httptest" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/sigstore/sigstore/pkg/cryptoutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// pushKeySignedArtifact pushes a random artifact plus a classic key-signed +// cosign signature manifest (payload layer + signature annotation at the +// sha256-.sig tag — what "cosign sign --key" produces) and returns the +// artifact ref and the signer's public key PEM. +func pushKeySignedArtifact(t *testing.T, host string) (ref string, pubPEM []byte, payload string) { + t.Helper() + + img, err := random.Image(256, 1) + require.NoError(t, err) + ref = host + "/test/artifact:v1" + parsedRef, err := name.ParseReference(ref) + require.NoError(t, err) + require.NoError(t, remote.Write(parsedRef, img)) + imgDigest, err := img.Digest() + require.NoError(t, err) + + // The simple-signing payload binds the artifact manifest digest. + payload = fmt.Sprintf( + `{"critical":{"identity":{"docker-reference":%q},"image":{"docker-manifest-digest":%q},`+ + `"type":"cosign container image signature"}}`, + strings.SplitN(ref, ":", 2)[0], imgDigest.String()) + + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + payloadDigest := sha256.Sum256([]byte(payload)) + sig, err := priv.Sign(rand.Reader, payloadDigest[:], nil) + require.NoError(t, err) + pubPEM, err = cryptoutils.MarshalPublicKeyToPEM(priv.Public()) + require.NoError(t, err) + + layer := static.NewLayer([]byte(payload), types.MediaType(MediaTypeCosignSimpleSigningV1JSON)) + sigImg, err := mutate.Append(empty.Image, mutate.Addendum{ + Layer: layer, + Annotations: map[string]string{ + "dev.cosignproject.cosign/signature": base64.StdEncoding.EncodeToString(sig), + }, + MediaType: types.MediaType(MediaTypeCosignSimpleSigningV1JSON), + }) + require.NoError(t, err) + sigImg = mutate.MediaType(sigImg, types.OCIManifestSchema1) + + sigTag := parsedRef.Context().Tag(fmt.Sprint(imgDigest.Algorithm, "-", imgDigest.Hex, ".sig")) + require.NoError(t, remote.Write(sigTag, sigImg)) + return ref, pubPEM, payload +} + +// TestRetrieveKeySignedBundleRoundTrip proves the classic key-signed cosign +// layout — signature annotation, no certificate, no tlog entry — is +// retrievable and verifies against the signing key. +func TestRetrieveKeySignedBundleRoundTrip(t *testing.T) { + t.Parallel() + + reg := httptest.NewServer(registry.New()) + t.Cleanup(reg.Close) + host := strings.TrimPrefix(reg.URL, "http://") + + ref, pubPEM, payload := pushKeySignedArtifact(t, host) + + bundles, err := RetrieveBundles(t.Context(), ref, nil) + require.NoError(t, err, "a key-signed signature manifest must be retrievable") + require.Len(t, bundles, 1) + assert.NotEmpty(t, bundles[0].Raw) + assert.False(t, bundles[0].HasCertificate(), "the key-signed layout carries no certificate") + + // The bundle's digest must bind the simple-signing payload — that is + // what the signature covers, per the cosign convention. + payloadDigest := sha256.Sum256([]byte(payload)) + assert.Equal(t, hex.EncodeToString(payloadDigest[:]), bundles[0].DigestHex, + "the bundle digest must be the simple-signing payload digest") + + _, err = VerifyBundleWithKey(bundles[0], pubPEM) + require.NoError(t, err, "the reconstructed bundle must verify against the signing key") + + // The stored Raw form re-verifies offline with the key. + _, err = VerifyBundleOfflineWithKey(bundles[0].Raw, DigestAlgorithmSHA256+":"+bundles[0].DigestHex, pubPEM) + require.NoError(t, err, "the stored bundle must re-verify offline with the key") + + // A different key must not verify it. + otherPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + otherPub, err := cryptoutils.MarshalPublicKeyToPEM(otherPriv.Public()) + require.NoError(t, err) + _, err = VerifyBundleWithKey(bundles[0], otherPub) + require.ErrorIs(t, err, ErrVerificationFailed) + _, err = VerifyBundleOfflineWithKey(bundles[0].Raw, DigestAlgorithmSHA256+":"+bundles[0].DigestHex, otherPub) + require.ErrorIs(t, err, ErrVerificationFailed) +} + +// TestKeySignedBundleFailsKeylessVerification ensures a reconstructed +// key-signed bundle is not silently trusted by the keyless (Fulcio) path. +func TestKeySignedBundleFailsKeylessVerification(t *testing.T) { + t.Parallel() + + reg := httptest.NewServer(registry.New()) + t.Cleanup(reg.Close) + host := strings.TrimPrefix(reg.URL, "http://") + + ref, _, _ := pushKeySignedArtifact(t, host) + bundles, err := RetrieveBundles(t.Context(), ref, nil) + require.NoError(t, err) + require.Len(t, bundles, 1) + + tm, err := OfflineTrustedMaterial() + require.NoError(t, err) + opts, err := DefaultVerifierOptions() + require.NoError(t, err) + _, err = VerifyBundle(bundles[0], tm, nil, opts...) + assert.ErrorIs(t, err, ErrVerificationFailed, + "a certificate-less bundle must not pass keyless verification") +} + +// TestCorruptCertificateIsNotReclassifiedAsKeySigned guards the layout +// classification: a layer whose certificate annotation is present but +// malformed is a broken keyless signature and must stay a retrieval +// failure — not silently become a "key-signed" bundle. +func TestCorruptCertificateIsNotReclassifiedAsKeySigned(t *testing.T) { + t.Parallel() + + reg := httptest.NewServer(registry.New()) + t.Cleanup(reg.Close) + host := strings.TrimPrefix(reg.URL, "http://") + + img, err := random.Image(256, 1) + require.NoError(t, err) + ref := host + "/test/corrupt:v1" + parsedRef, err := name.ParseReference(ref) + require.NoError(t, err) + require.NoError(t, remote.Write(parsedRef, img)) + imgDigest, err := img.Digest() + require.NoError(t, err) + + layer := static.NewLayer([]byte(`{"critical":{}}`), types.MediaType(MediaTypeCosignSimpleSigningV1JSON)) + sigImg, err := mutate.Append(empty.Image, mutate.Addendum{ + Layer: layer, + Annotations: map[string]string{ + "dev.cosignproject.cosign/signature": base64.StdEncoding.EncodeToString([]byte("sig")), + "dev.sigstore.cosign/certificate": "not a pem certificate", + }, + MediaType: types.MediaType(MediaTypeCosignSimpleSigningV1JSON), + }) + require.NoError(t, err) + sigImg = mutate.MediaType(sigImg, types.OCIManifestSchema1) + sigTag := parsedRef.Context().Tag(fmt.Sprint(imgDigest.Algorithm, "-", imgDigest.Hex, ".sig")) + require.NoError(t, remote.Write(sigTag, sigImg)) + + _, err = RetrieveBundles(t.Context(), ref, nil) + require.ErrorIs(t, err, ErrNoBundles, + "a corrupt certificate must not be reclassified as a key-signed bundle") +} diff --git a/container/verifier/sigstore.go b/container/verifier/sigstore.go index 2d13876..acd92db 100644 --- a/container/verifier/sigstore.go +++ b/container/verifier/sigstore.go @@ -53,14 +53,16 @@ func bundleFromSigstoreSignedImage(ctx context.Context, imageRef string, keychai // Build the verification material for the bundle verificationMaterial, err := getBundleVerificationMaterial(layer) if err != nil { - slog.Error("error getting bundle verification material") + slog.Error("error getting bundle verification material", + "layer_digest", layer.Digest.String(), "error", err) continue } // Build the message signature for the bundle msgSignature, err := getBundleMsgSignature(layer) if err != nil { - slog.Error("error getting bundle message signature") + slog.Error("error getting bundle message signature", + "layer_digest", layer.Digest.String(), "error", err) continue } @@ -166,7 +168,23 @@ func getSimpleSigningLayersFromSignatureManifest( // getBundleVerificationMaterial returns the bundle verification material from the simple signing layer func getBundleVerificationMaterial(manifestLayer v1.Descriptor) ( *protobundle.VerificationMaterial, error) { - // 1. Get the signing certificate chain + // 1. Classify the layout by certificate annotation PRESENCE. A layer + // without one is the classic key-signed cosign layout ("cosign sign + // --key"): the only verification material is the signature itself, and + // trust is established by the verifier supplying the public key. A + // certificate annotation that is present but malformed must remain an + // error — the annotations are registry-supplied (attacker-controlled), + // and a corrupt keyless layer must not be reclassified as key-signed. + if manifestLayer.Annotations["dev.sigstore.cosign/certificate"] == "" { + if !hasCosignSignatureAnnotation(manifestLayer) { + return nil, errors.New("layer carries neither certificate nor signature annotation") + } + return &protobundle.VerificationMaterial{ + Content: &protobundle.VerificationMaterial_PublicKey{ + PublicKey: &protocommon.PublicKeyIdentifier{Hint: keySignedPublicKeyHint}, + }, + }, nil + } signingCert, err := getVerificationMaterialX509CertificateChain(manifestLayer) if err != nil { return nil, fmt.Errorf("error getting signing certificate: %w", err) @@ -185,6 +203,18 @@ func getBundleVerificationMaterial(manifestLayer v1.Descriptor) ( }, nil } +// keySignedPublicKeyHint marks a reconstructed bundle as originating from +// the key-signed cosign layout: the stored bundle carries no key material +// itself, so offline re-verification requires the caller to supply the +// public key (VerifyBundleOfflineWithKey). +const keySignedPublicKeyHint = "cosign-keypair" + +// hasCosignSignatureAnnotation reports whether the simple signing layer +// carries a cosign signature annotation. +func hasCosignSignatureAnnotation(layer v1.Descriptor) bool { + return layer.Annotations["dev.cosignproject.cosign/signature"] != "" +} + // getVerificationMaterialX509CertificateChain returns the verification material X509 certificate chain from the // simple signing layer func getVerificationMaterialX509CertificateChain(manifestLayer v1.Descriptor) (