Skip to content

Commit

Permalink
[Signature discovery] Add a signature interface and a library to pars…
Browse files Browse the repository at this point in the history
…e signature from image manifest (#328)

* Adding a signature interface and its implementations to parse cosign-generated signatures from OCI image object
  • Loading branch information
yawangwang authored Aug 16, 2023
1 parent bf3ecd5 commit 9e1f35d
Show file tree
Hide file tree
Showing 5 changed files with 328 additions and 6 deletions.
70 changes: 70 additions & 0 deletions launcher/internal/oci/cosign/signature.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Package cosign contains functionalities to interact with signatures generated by cosign.
// https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md.
package cosign

import (
"encoding/base64"
"errors"
"fmt"

"github.com/google/go-tpm-tools/launcher/internal/oci"
"github.com/opencontainers/go-digest"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)

// Sig implements oci.Signature interface for cosign-generated signatures.
type Sig struct {
// Layer represents a layer descriptor for OCI image manifest.
// This contains the simple signing payload digest and Cosign signature,
// collected from the OCI image manifest object found using https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md#tag-based-discovery.
Layer v1.Descriptor
// Blob represents the opaque data uploaded to OCI registry associated with the layer.
// This contains the Simple Signing Payload as described in https://github.com/sigstore/cosign/blob/main/specs/SIGNATURE_SPEC.md#tag-based-discovery.
Blob []byte
}

// CosignSigKey is the key of the cosign-generated signature embedded in OCI image manifest.
const CosignSigKey = "dev.cosignproject.cosign/signature"

var (
// Verify that our Sig struct implements the expected public interface.
_ oci.Signature = Sig{}
encoding = base64.StdEncoding
)

// Payload implements oci.Signature interface.
func (s Sig) Payload() ([]byte, error) {
// The payload bytes are uploaded to an OCI registry as blob, and are referenced by digest.
// This digiest is embedded into the OCI image manifest as a layer via a descriptor (see https://github.com/opencontainers/image-spec/blob/main/descriptor.md).
// Here we compare the digest of the blob data with the layer digest to verify if this blob is associated with the layer.
if digest.FromBytes(s.Blob) != s.Layer.Digest {
return nil, errors.New("an unmatched payload digest is paired with a layer descriptor digest")
}
return s.Blob, nil
}

// Base64Encoded implements oci.Signature interface.
func (s Sig) Base64Encoded() (string, error) {
sig, ok := s.Layer.Annotations[CosignSigKey]
if !ok {
return "", errors.New("cosign signature not found in the layer annotations")
}
if _, err := encoding.DecodeString(sig); err != nil {
return "", fmt.Errorf("invalid base64 encoded signature: %w", err)
}
return sig, nil
}

// PublicKey implements oci.Signature interface.
// Since public key is attached to the `optional` field of payload, we don't actually implement this method.
// Instead we send payload directly to the Attestation service and let the service parse the payload.
func (s Sig) PublicKey() ([]byte, error) {
return nil, fmt.Errorf("not implemented")
}

// SigningAlgorithm implements oci.Signature interface.
// Since signing algorithm is attached to the `optional` field of payload, we don't actually implement this method.
// Instead we send payload directly to the Attestation service and let the service parse the payload.
func (s Sig) SigningAlgorithm() (oci.SigningAlgorithm, error) {
return "", fmt.Errorf("not implemented")
}
140 changes: 140 additions & 0 deletions launcher/internal/oci/cosign/signature_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package cosign

import (
"bytes"
"crypto/rand"
"testing"

"github.com/opencontainers/go-digest"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)

func TestPayload(t *testing.T) {
testCases := []struct {
name string
blob []byte
wantDigest digest.Digest
wantPayload []byte
wantPass bool
}{
{
name: "cosign signature Payload() success",
blob: []byte(`{"critical":{"identity":{"docker-reference":"us-docker.pkg.dev/confidential-space-images-dev/cs-cosign-tests/base"},"image":{"docker-manifest-digest":"sha256:9494e567c7c44e8b9f8808c1658a47c9b7979ef3cceef10f48754fc2706802ba"},"type":"cosign container image signature"},"optional":null}`),
wantDigest: "sha256:d1e44a76902409836227b982beb920189949927c2011f196594bd34c5bb8f8b1",
wantPayload: []byte(`{"critical":{"identity":{"docker-reference":"us-docker.pkg.dev/confidential-space-images-dev/cs-cosign-tests/base"},"image":{"docker-manifest-digest":"sha256:9494e567c7c44e8b9f8808c1658a47c9b7979ef3cceef10f48754fc2706802ba"},"type":"cosign container image signature"},"optional":null}`),
wantPass: true,
},
{
name: "cosign signature Payload() failed with unmatched digest",
blob: []byte(`{"critical":{"identity":{"docker-reference":"us-docker.pkg.dev/confidential-space-images-dev/cs-cosign-tests/base"},"image":{"docker-manifest-digest":"sha256:9494e567c7c44e8b9f8808c1658a47c9b7979ef3cceef10f48754fc2706802ba"},"type":"cosign container image signature"},"optional":null}`),
wantDigest: "sha256:unmatched digest",
wantPayload: []byte{},
wantPass: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sig := &Sig{
Layer: v1.Descriptor{
Digest: tc.wantDigest,
},
Blob: tc.blob,
}
gotPayload, err := sig.Payload()
if err != nil && tc.wantPass {
t.Errorf("Payload() failed for test case %v: %v", tc.name, err)
}
if !bytes.Equal(gotPayload, tc.wantPayload) {
t.Errorf("Payload() failed for test case %v: got %v, but want %v", tc.name, gotPayload, tc.wantPayload)
}
})
}
}

func TestBase64Encoded(t *testing.T) {
testCases := []struct {
name string
wantSignatureKey string
wantSignature string
wantPass bool
}{
{
name: "cosign signature Base64Encoded() success",
wantSignatureKey: CosignSigKey,
wantSignature: randomBase64EncodedString(32),
wantPass: true,
},
{
name: "cosign signature Base64Encoded() failed with mismatched signature key",
wantSignatureKey: "mismatched signature key",
wantSignature: "",
wantPass: false,
},
{
name: "cosign signature Base64Encoded() failed with invalid base64 encoded signature",
wantSignatureKey: CosignSigKey,
wantSignature: "",
wantPass: false,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
sig := &Sig{
Layer: v1.Descriptor{
Annotations: map[string]string{
tc.wantSignatureKey: tc.wantSignature,
},
},
}
gotSignature, err := sig.Base64Encoded()
if err != nil && tc.wantPass {
t.Errorf("Base64Encoded() failed for test case %v: %v", tc.name, err)
}
if gotSignature != tc.wantSignature {
t.Errorf("Base64Encoded() failed for test case %v: got %v, but want %v", tc.name, gotSignature, tc.wantSignature)
}
})
}
}

func TestWorkflow(t *testing.T) {
wantSig := randomBase64EncodedString(32)
blob := []byte(`{"critical":{"identity":{"docker-reference":"us-docker.pkg.dev/confidential-space-images-dev/cs-cosign-tests/base"},"image":{"docker-manifest-digest":"sha256:9494e567c7c44e8b9f8808c1658a47c9b7979ef3cceef10f48754fc2706802ba"},"type":"cosign container image signature"},"optional":null}`)

sig := &Sig{
Layer: v1.Descriptor{
Digest: digest.FromBytes(blob),
Annotations: map[string]string{
CosignSigKey: wantSig,
},
},
Blob: blob,
}

gotPayload, err := sig.Payload()
if err != nil {
t.Errorf("Payload() failed: %v", err)
}
if !bytes.Equal(gotPayload, blob) {
t.Errorf("Payload() failed: got %v, but want %v", gotPayload, blob)
}

gotSig, err := sig.Base64Encoded()
if err != nil {
t.Errorf("Base64Encoded() failed: %v", err)
}
if gotSig != wantSig {
t.Errorf("Base64Encoded() failed, got %s, but want %s", gotSig, wantSig)
}
}

func randomBase64EncodedString(n int) string {
b := make([]byte, n)
_, err := rand.Read(b)
if err != nil {
return ""
}
return encoding.EncodeToString(b)
}
45 changes: 45 additions & 0 deletions launcher/internal/oci/interface.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Package oci contains functionalities to interact with OCI image signatures.
// https://github.com/opencontainers/image-spec/tree/main#readme.
package oci

// SigningAlgorithm is a specific type for string constants used for sigature signing and verification.
type SigningAlgorithm string

const (
// RSASSAPSS2048SHA256 is RSASSA-PSS 2048 bit key with a SHA256 digest supported for cosign sign.
RSASSAPSS2048SHA256 SigningAlgorithm = "RSASSA_PSS_SHA256"
// RSASSAPSS3072SHA256 is RSASSA-PSS 3072 bit key with a SHA256 digest supported for cosign sign.
RSASSAPSS3072SHA256 SigningAlgorithm = "RSASSA_PSS_SHA256"
// RSASSAPSS4096SHA256 is RSASSA-PSS 4096 bit key with a SHA256 digest supported for cosign sign.
RSASSAPSS4096SHA256 SigningAlgorithm = "RSASSA_PSS_SHA256"
// RSASSAPKCS1V152048SHA256 is RSASSA-PKCS1 v1.5 2048 bit key with a SHA256 digest supported for cosign sign.
RSASSAPKCS1V152048SHA256 SigningAlgorithm = "RSASSA_PKCS1V15_SHA256"
// RSASSAPKCS1V153072SHA256 is RSASSA-PKCS1 v1.5 3072 bit key with a SHA256 digest supported for cosign sign.
RSASSAPKCS1V153072SHA256 SigningAlgorithm = "RSASSA_PKCS1V15_SHA256"
// RSASSAPKCS1V154096SHA256 is RSASSA-PKCS1 v1.5 4096 bit key with a SHA256 digest supported for cosign sign.
RSASSAPKCS1V154096SHA256 SigningAlgorithm = "RSASSA_PKCS1V15_SHA256"
// ECDSAP256SHA256 is ECDSA on the P-256 Curve with a SHA256 digest supported for cosign sign.
ECDSAP256SHA256 SigningAlgorithm = "ECDSA_P256_SHA256"
)

// Signature represents a single OCI image signature.
type Signature interface {
// Payload returns the blob data associated with a signature uploaded to an OCI registry.
Payload() ([]byte, error)

// Base64Encoded returns the base64-encoded signature of the signed payload.
Base64Encoded() (string, error)

// PublicKey returns a public key in the format of PEM-encoded byte slice.
PublicKey() ([]byte, error)

// SigningAlgorithm returns the signing algorithm specifications in the format of:
// 1. RSASSAPSS2048SHA256 (RSASSA algorithm with PSS padding 2048 bit key with a SHA256 digest)
// 2. RSASSAPSS3072SHA256 (RSASSA algorithm with PSS padding 3072 bit key with a SHA256 digest)
// 3. RSASSAPSS4096SHA256 (RSASSA algorithm with PSS padding 4096 bit key with a SHA256 digest)
// 4. RSASSAPKCS1V152048SHA256 (RSASSA algorithm with PKCS #1 v1.5 padding 2048 bit key with a SHA256 digest)
// 5. RSASSAPKCS1V153072SHA256 (RSASSA algorithm with PKCS #1 v1.5 padding 3072 bit key with a SHA256 digest)
// 6. RSASSAPKCS1V154096SHA256 (RSASSA algorithm with PKCS #1 v1.5 padding 4096 bit key with a SHA256 digest)
// 7. ECDSAP256SHA256 (ECDSA on the P-256 Curve with a SHA256 digest)
SigningAlgorithm() (SigningAlgorithm, error)
}
28 changes: 28 additions & 0 deletions launcher/internal/signaturediscovery/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import (
"fmt"

"github.com/containerd/containerd"
"github.com/containerd/containerd/content"
"github.com/containerd/containerd/images"
"github.com/google/go-tpm-tools/launcher/internal/oci"
"github.com/google/go-tpm-tools/launcher/internal/oci/cosign"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)

Expand Down Expand Up @@ -37,6 +40,31 @@ func (c *Client) FetchSignedImageManifest(ctx context.Context, targetRepository
return getManifest(ctx, image)
}

// FetchImageSignatures returns a list of valid image signatures associated with the target OCI image.
func (c *Client) FetchImageSignatures(ctx context.Context, targetRepository string) ([]oci.Signature, error) {
image, err := c.pullTargetImage(ctx, targetRepository)
if err != nil {
return nil, err
}
manifest, err := getManifest(ctx, image)
if err != nil {
return nil, err
}
signatures := make([]oci.Signature, 0, len(manifest.Layers))
for _, layer := range manifest.Layers {
blob, err := content.ReadBlob(ctx, image.ContentStore(), layer)
if err != nil {
return nil, err
}
sig := &cosign.Sig{
Layer: layer,
Blob: blob,
}
signatures = append(signatures, sig)
}
return signatures, nil
}

func (c *Client) pullTargetImage(ctx context.Context, targetRepository string) (containerd.Image, error) {
targetImageRef := fmt.Sprint(targetRepository, ":", formatSigTag(c.OriginalImageDesc))
image, err := c.cdClient.Pull(ctx, targetImageRef, c.RemoteOpts...)
Expand Down
51 changes: 45 additions & 6 deletions launcher/internal/signaturediscovery/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/containerd/containerd"
"github.com/containerd/containerd/defaults"
"github.com/containerd/containerd/namespaces"
"github.com/google/go-cmp/cmp"
v1 "github.com/opencontainers/image-spec/specs-go/v1"
)

Expand Down Expand Up @@ -48,17 +49,55 @@ func TestFormatSigTag(t *testing.T) {

func TestFetchSignedImageManifestDockerPublic(t *testing.T) {
ctx := namespaces.WithNamespace(context.Background(), "test")
containerdClient, err := containerd.New(defaults.DefaultAddress)
if err != nil {
t.Skipf("test needs containerd daemon: %v", err)
}
defer containerdClient.Close()

targetRepository := "gcr.io/distroless/static"
originalImageDesc := v1.Descriptor{Digest: "sha256:9ecc53c269509f63c69a266168e4a687c7eb8c0cfd753bd8bfcaa4f58a90876f"}
client := New(containerdClient, originalImageDesc)
client := createTestClient(t, originalImageDesc)
// testing image manifest fetching using a public docker repo url
if _, err := client.FetchSignedImageManifest(ctx, targetRepository); err != nil {
t.Errorf("failed to fetch signed image manifest from targetRepository [%s]: %v", targetRepository, err)
}
}

func TestFetchImageSignaturesDockerPublic(t *testing.T) {
ctx := namespaces.WithNamespace(context.Background(), "test")
originalImageDesc := v1.Descriptor{Digest: "sha256:905a0f3b3d6d0fb37bfa448b9e78f833b73f0b19fc97fed821a09cf49e255df1"}
targetRepository := "us-docker.pkg.dev/vegas-codelab-5/cosign-test/base"

client := createTestClient(t, originalImageDesc)
signatures, err := client.FetchImageSignatures(ctx, targetRepository)
if err != nil {
t.Errorf("failed to fetch image signatures from targetRepository [%s]: %v", targetRepository, err)
}
if len(signatures) == 0 {
t.Errorf("no image signatures found for the original image %v", originalImageDesc)
}
var gotBase64Sigs []string
for _, sig := range signatures {
if _, err := sig.Payload(); err != nil {
t.Errorf("Payload() failed: %v", err)
}
base64Sig, err := sig.Base64Encoded()
if err != nil {
t.Errorf("Base64Encoded() failed: %v", err)
}
gotBase64Sigs = append(gotBase64Sigs, base64Sig)
}

// Check signatures from the OCI image manifest at https://pantheon.corp.google.com/artifacts/docker/vegas-codelab-5/us/cosign-test/base/sha256:1febaa6ac3a5c095435d5276755fb8efcb7f029fefe85cd9bf3ec7de91685b9f;tab=manifest?project=vegas-codelab-5.
wantBase64Sigs := []string{"MEUCIQDgoiwMiVl1SAI1iePhH6Oeqztms3IwNtN+w0P92HTqQgIgKjJNcHEy0Ep4g4MH1Vd0gAHvbwH9ahD+jlnMP/rXSGE="}
if !cmp.Equal(gotBase64Sigs, wantBase64Sigs) {
t.Errorf("signatures did not return expected base64 signatures, got %v, want %v", gotBase64Sigs, wantBase64Sigs)
}
}

func createTestClient(t *testing.T, originalImageDesc v1.Descriptor) *Client {
t.Helper()

containerdClient, err := containerd.New(defaults.DefaultAddress)
if err != nil {
t.Skipf("test needs containerd daemon: %v", err)
}
t.Cleanup(func() { containerdClient.Close() })
return New(containerdClient, originalImageDesc)
}

0 comments on commit 9e1f35d

Please sign in to comment.