Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions container/verifier/bundles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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:<hex>") 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 <algorithm>:<hex> 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
Expand Down
5 changes: 5 additions & 0 deletions container/verifier/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
179 changes: 179 additions & 0 deletions container/verifier/keysigned_test.go
Original file line number Diff line number Diff line change
@@ -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-<hex>.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")
}
36 changes: 33 additions & 3 deletions container/verifier/sigstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
Expand All @@ -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) (
Expand Down