Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Signature discovery] Add a signature interface and a library to parse signature from image manifest #328

Merged
merged 3 commits into from
Aug 16, 2023
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
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.
yawangwang marked this conversation as resolved.
Show resolved Hide resolved
// 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 {
yawangwang marked this conversation as resolved.
Show resolved Hide resolved
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.
yawangwang marked this conversation as resolved.
Show resolved Hide resolved
// 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
yawangwang marked this conversation as resolved.
Show resolved Hide resolved

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 {
yawangwang marked this conversation as resolved.
Show resolved Hide resolved
// 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) {
yawangwang marked this conversation as resolved.
Show resolved Hide resolved
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"}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the codelab project make this repo public? Otherwise, the test will fail for someone without permissions.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this repo only grants roles/artifactregistry.reader permission to all users, so it is a public accessible repo.

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)
}
yawangwang marked this conversation as resolved.
Show resolved Hide resolved
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)
yawangwang marked this conversation as resolved.
Show resolved Hide resolved
}
t.Cleanup(func() { containerdClient.Close() })
return New(containerdClient, originalImageDesc)
}