diff --git a/launcher/internal/oci/cosign/signature.go b/launcher/internal/oci/cosign/signature.go new file mode 100644 index 00000000..14016709 --- /dev/null +++ b/launcher/internal/oci/cosign/signature.go @@ -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") +} diff --git a/launcher/internal/oci/cosign/signature_test.go b/launcher/internal/oci/cosign/signature_test.go new file mode 100644 index 00000000..8e59cf1d --- /dev/null +++ b/launcher/internal/oci/cosign/signature_test.go @@ -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) +} diff --git a/launcher/internal/oci/interface.go b/launcher/internal/oci/interface.go new file mode 100644 index 00000000..064a73ef --- /dev/null +++ b/launcher/internal/oci/interface.go @@ -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) +} diff --git a/launcher/internal/signaturediscovery/client.go b/launcher/internal/signaturediscovery/client.go index 30545cd1..cdc9741b 100644 --- a/launcher/internal/signaturediscovery/client.go +++ b/launcher/internal/signaturediscovery/client.go @@ -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" ) @@ -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...) diff --git a/launcher/internal/signaturediscovery/client_test.go b/launcher/internal/signaturediscovery/client_test.go index 6362720c..1205eb12 100644 --- a/launcher/internal/signaturediscovery/client_test.go +++ b/launcher/internal/signaturediscovery/client_test.go @@ -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" ) @@ -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) +}