diff --git a/internal/signature/cosign.go b/internal/signature/cosign.go new file mode 100644 index 0000000000..b4b67f2127 --- /dev/null +++ b/internal/signature/cosign.go @@ -0,0 +1,84 @@ +package signature + +import "encoding/json" + +const ( + // from sigstore/cosign/pkg/types.SimpleSigningMediaType + CosignSignatureMIMEType = "application/vnd.dev.cosign.simplesigning.v1+json" + // from sigstore/cosign/pkg/oci/static.SignatureAnnotationKey + CosignSignatureAnnotationKey = "dev.cosignproject.cosign/signature" +) + +// Cosign is a github.com/Cosign/cosign signature. +// For the persistent-storage format used for blobChunk(), we want +// a degree of forward compatibility against unexpected field changes +// (as has happened before), which is why this data type +// contains just a payload + annotations (including annotations +// that we don’t recognize or support), instead of individual fields +// for the known annotations. +type Cosign struct { + untrustedMIMEType string + untrustedPayload []byte + untrustedAnnotations map[string]string +} + +// cosignJSONRepresentation needs the files to be public, which we don’t want for +// the main Cosign type. +type cosignJSONRepresentation struct { + UntrustedMIMEType string `json:"mimeType"` + UntrustedPayload []byte `json:"payload"` + UntrustedAnnotations map[string]string `json:"annotations"` +} + +// CosignFromComponents returns a Cosign object from its components. +func CosignFromComponents(untrustedMimeType string, untrustedPayload []byte, untrustedAnnotations map[string]string) Cosign { + return Cosign{ + untrustedMIMEType: untrustedMimeType, + untrustedPayload: copyByteSlice(untrustedPayload), + untrustedAnnotations: copyStringMap(untrustedAnnotations), + } +} + +// cosignFromBlobChunk converts a Cosign signature, as returned by Cosign.blobChunk, into a Cosign object. +func cosignFromBlobChunk(blobChunk []byte) (Cosign, error) { + var v cosignJSONRepresentation + if err := json.Unmarshal(blobChunk, &v); err != nil { + return Cosign{}, err + } + return CosignFromComponents(v.UntrustedMIMEType, + v.UntrustedPayload, + v.UntrustedAnnotations), nil +} + +func (s Cosign) FormatID() FormatID { + return CosignFormat +} + +// blobChunk returns a representation of signature as a []byte, suitable for long-term storage. +// Almost everyone should use signature.Blob() instead. +func (s Cosign) blobChunk() ([]byte, error) { + return json.Marshal(cosignJSONRepresentation{ + UntrustedMIMEType: s.UntrustedMIMEType(), + UntrustedPayload: s.UntrustedPayload(), + UntrustedAnnotations: s.UntrustedAnnotations(), + }) +} + +func (s Cosign) UntrustedMIMEType() string { + return s.untrustedMIMEType +} +func (s Cosign) UntrustedPayload() []byte { + return copyByteSlice(s.untrustedPayload) +} + +func (s Cosign) UntrustedAnnotations() map[string]string { + return copyStringMap(s.untrustedAnnotations) +} + +func copyStringMap(m map[string]string) map[string]string { + res := map[string]string{} + for k, v := range m { + res[k] = v + } + return res +} diff --git a/internal/signature/cosign_test.go b/internal/signature/cosign_test.go new file mode 100644 index 0000000000..422c60efa7 --- /dev/null +++ b/internal/signature/cosign_test.go @@ -0,0 +1,71 @@ +package signature + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCosignFromComponents(t *testing.T) { + const mimeType = "mime-type" + payload := []byte("payload") + annotations := map[string]string{"a": "b", "c": "d"} + + sig := CosignFromComponents(mimeType, payload, annotations) + assert.Equal(t, Cosign{ + untrustedMIMEType: mimeType, + untrustedPayload: payload, + untrustedAnnotations: annotations, + }, sig) +} + +func TestCosignFromBlobChunk(t *testing.T) { + // Success + json := []byte(`{"mimeType":"mime-type","payload":"cGF5bG9hZA==", "annotations":{"a":"b","c":"d"}}`) + res, err := cosignFromBlobChunk(json) + require.NoError(t, err) + assert.Equal(t, "mime-type", res.UntrustedMIMEType()) + assert.Equal(t, []byte("payload"), res.UntrustedPayload()) + assert.Equal(t, map[string]string{"a": "b", "c": "d"}, res.UntrustedAnnotations()) + + // Invalid JSON + _, err = cosignFromBlobChunk([]byte("&")) + assert.Error(t, err) +} + +func TestCosignFormatID(t *testing.T) { + sig := CosignFromComponents("mime-type", []byte("payload"), + map[string]string{"a": "b", "c": "d"}) + assert.Equal(t, CosignFormat, sig.FormatID()) +} + +func TestCosign_blobChunk(t *testing.T) { + sig := CosignFromComponents("mime-type", []byte("payload"), + map[string]string{"a": "b", "c": "d"}) + res, err := sig.blobChunk() + require.NoError(t, err) + + expectedJSON := []byte(`{"mimeType":"mime-type","payload":"cGF5bG9hZA==", "annotations":{"a":"b","c":"d"}}`) + // Don’t directly compare the JSON representation so that we don’t test for formatting differences, just verify that it contains exactly the expected data. + var raw, expectedRaw map[string]interface{} + err = json.Unmarshal(res, &raw) + require.NoError(t, err) + err = json.Unmarshal(expectedJSON, &expectedRaw) + require.NoError(t, err) + assert.Equal(t, expectedRaw, raw) +} + +func TestCosign_UntrustedPayload(t *testing.T) { + var payload = []byte("payload") + sig := CosignFromComponents("mime-type", payload, + map[string]string{"a": "b", "c": "d"}) + assert.Equal(t, payload, sig.UntrustedPayload()) +} + +func TestCosign_UntrustedAnnotations(t *testing.T) { + annotations := map[string]string{"a": "b", "c": "d"} + sig := CosignFromComponents("mime-type", []byte("payload"), annotations) + assert.Equal(t, annotations, sig.UntrustedAnnotations()) +} diff --git a/internal/signature/signature.go b/internal/signature/signature.go index 1b1bee4287..92d66a620f 100644 --- a/internal/signature/signature.go +++ b/internal/signature/signature.go @@ -81,7 +81,7 @@ func FromBlob(blob []byte) (Signature, error) { case bytes.Equal(formatBytes, []byte(SimpleSigningFormat)): return SimpleSigningFromBlob(blobChunk), nil case bytes.Equal(formatBytes, []byte(CosignFormat)): - fallthrough + return cosignFromBlobChunk(blobChunk) default: return nil, fmt.Errorf("unrecognized signature format %q", string(formatBytes)) } diff --git a/internal/signature/signature_test.go b/internal/signature/signature_test.go index df8e4ed52a..18d975a259 100644 --- a/internal/signature/signature_test.go +++ b/internal/signature/signature_test.go @@ -1,6 +1,8 @@ package signature import ( + "bytes" + "fmt" "os" "testing" @@ -22,6 +24,45 @@ func TestBlobSimpleSigning(t *testing.T) { fromBlobSimple, ok := fromBlob.(SimpleSigning) require.True(t, ok) assert.Equal(t, simpleSigData, fromBlobSimple.UntrustedSignature()) + + // Using the newer format is accepted as well. + fromBlob, err = FromBlob(append([]byte("\x00simple-signing\n"), simpleSigData...)) + require.NoError(t, err) + fromBlobSimple, ok = fromBlob.(SimpleSigning) + require.True(t, ok) + assert.Equal(t, simpleSigData, fromBlobSimple.UntrustedSignature()) + +} + +func TestBlobCosign(t *testing.T) { + cosignSig := CosignFromComponents("mime-type", []byte("payload"), + map[string]string{"a": "b", "c": "d"}) + + cosignBlob, err := Blob(cosignSig) + require.NoError(t, err) + assert.True(t, bytes.HasPrefix(cosignBlob, []byte("\x00cosign-json\n{"))) + + fromBlob, err := FromBlob(cosignBlob) + require.NoError(t, err) + fromBlobCosign, ok := fromBlob.(Cosign) + require.True(t, ok) + assert.Equal(t, cosignSig.UntrustedMIMEType(), fromBlobCosign.UntrustedMIMEType()) + assert.Equal(t, cosignSig.UntrustedPayload(), fromBlobCosign.UntrustedPayload()) + assert.Equal(t, cosignSig.UntrustedAnnotations(), fromBlobCosign.UntrustedAnnotations()) +} + +func TestFromBlobInvalid(t *testing.T) { + // Round-tripping valid data has been tested in TestBlobSimpleSigning and TestBlobCosign above. + for _, c := range []string{ + "", // Empty + "\xFFsimple-signing\nhello", // Invalid first byte + "\x00simple-signing", // No newline + "\x00format\xFFname\ndata", // Non-ASCII format value + "\x00unknown-format\ndata", // Unknown format + } { + _, err := FromBlob([]byte(c)) + assert.Error(t, err, fmt.Sprintf("%#v", c)) + } } // mockFormatSignature returns a specified format @@ -44,7 +85,7 @@ func TestUnsuportedFormatError(t *testing.T) { expected string }{ {SimpleSigningFromBlob(nil), "unsupported signature format simple-signing"}, - {mockFormatSignature{CosignFormat}, "unsupported signature format cosign-json"}, + {CosignFromComponents("mime-type", nil, nil), "unsupported signature format cosign-json"}, {mockFormatSignature{FormatID("invalid")}, `unsupported, and unrecognized, signature format "invalid"`}, } { res := UnsupportedFormatError(c.input)