Skip to content

Commit

Permalink
Add signature.untrustedCosignPayload, with JSON marshaling
Browse files Browse the repository at this point in the history
The code and tests are basically copy&pasted from simple signing.

Signed-off-by: Miloslav Trmač <mitr@redhat.com>
  • Loading branch information
mtrmac committed Jul 6, 2022
1 parent 103fb71 commit ceacd85
Show file tree
Hide file tree
Showing 3 changed files with 369 additions and 0 deletions.
160 changes: 160 additions & 0 deletions signature/internal/cosign_payload.go
@@ -0,0 +1,160 @@
package internal

import (
"encoding/json"
"errors"
"fmt"
"time"

"github.com/containers/image/v5/version"
digest "github.com/opencontainers/go-digest"
)

const (
cosignSignatureType = "cosign container image signature"
)

// UntrustedCosignPayload is a parsed content of a Cosign signature payload (not the full signature)
type UntrustedCosignPayload struct {
UntrustedDockerManifestDigest digest.Digest
UntrustedDockerReference string // FIXME: more precise type?
UntrustedCreatorID *string
// This is intentionally an int64; the native JSON float64 type would allow to represent _some_ sub-second precision,
// but not nearly enough (with current timestamp values, a single unit in the last place is on the order of hundreds of nanoseconds).
// So, this is explicitly an int64, and we reject fractional values. If we did need more precise timestamps eventually,
// we would add another field, UntrustedTimestampNS int64.
UntrustedTimestamp *int64
}

// NewUntrustedCosignPayload returns an untrustedCosignPayload object with
// the specified primary contents and appropriate metadata.
func NewUntrustedCosignPayload(dockerManifestDigest digest.Digest, dockerReference string) UntrustedCosignPayload {
// FIXME: Move this comment to the caller.
// AARGH. sigstore/cosign mostly ignores dockerReference for actual policy decisions.
// That’s bad enough, BUT they also:
// - Record the repo (but NOT THE TAG) in the value; without the tag we can’t detect version rollbacks.
// - parse dockerReference @ dockerManifestDigest and expect that to be valid.
// - It seems (FIXME: TEST THIS) that putting a repo:tag in would pass the current implementation.
// And signing digest references is _possible_ but probably rare (because signing typically happens on push, when
// the digest reference is not known in advance).
// SO: We put the full value in, which is not interoperable for signed digest references right now,
// and TODO: Talk sigstore/cosign to relax that.
//
// Use intermediate variables for these values so that we can take their addresses.
// Golang guarantees that they will have a new address on every execution.
creatorID := "containers/image " + version.Version
timestamp := time.Now().Unix()
return UntrustedCosignPayload{
UntrustedDockerManifestDigest: dockerManifestDigest,
UntrustedDockerReference: dockerReference,
UntrustedCreatorID: &creatorID,
UntrustedTimestamp: &timestamp,
}
}

// Compile-time check that untrustedCosignPayload implements json.Marshaler
var _ json.Marshaler = (*UntrustedCosignPayload)(nil)

// MarshalJSON implements the json.Marshaler interface.
func (s UntrustedCosignPayload) MarshalJSON() ([]byte, error) {
if s.UntrustedDockerManifestDigest == "" || s.UntrustedDockerReference == "" {
return nil, errors.New("Unexpected empty signature content")
}
critical := map[string]interface{}{
"type": cosignSignatureType,
"image": map[string]string{"docker-manifest-digest": s.UntrustedDockerManifestDigest.String()},
"identity": map[string]string{"docker-reference": s.UntrustedDockerReference},
}
optional := map[string]interface{}{}
if s.UntrustedCreatorID != nil {
optional["creator"] = *s.UntrustedCreatorID
}
if s.UntrustedTimestamp != nil {
optional["timestamp"] = *s.UntrustedTimestamp
}
signature := map[string]interface{}{
"critical": critical,
"optional": optional,
}
return json.Marshal(signature)
}

// Compile-time check that untrustedCosignPayload implements json.Unmarshaler
var _ json.Unmarshaler = (*UntrustedCosignPayload)(nil)

// UnmarshalJSON implements the json.Unmarshaler interface
func (s *UntrustedCosignPayload) UnmarshalJSON(data []byte) error {
err := s.strictUnmarshalJSON(data)
if err != nil {
if formatErr, ok := err.(JSONFormatError); ok {
err = NewInvalidSignatureError(formatErr.Error())
}
}
return err
}

// strictUnmarshalJSON is UnmarshalJSON, except that it may return the internal JSONFormatError error type.
// Splitting it into a separate function allows us to do the JSONFormatError → InvalidSignatureError in a single place, the caller.
func (s *UntrustedCosignPayload) strictUnmarshalJSON(data []byte) error {
var critical, optional json.RawMessage
if err := ParanoidUnmarshalJSONObjectExactFields(data, map[string]interface{}{
"critical": &critical,
"optional": &optional,
}); err != nil {
return err
}

var creatorID string
var timestamp float64
var gotCreatorID, gotTimestamp = false, false
if err := ParanoidUnmarshalJSONObject(optional, func(key string) interface{} {
switch key {
case "creator":
gotCreatorID = true
return &creatorID
case "timestamp":
gotTimestamp = true
return &timestamp
default:
var ignore interface{}
return &ignore
}
}); err != nil {
return err
}
if gotCreatorID {
s.UntrustedCreatorID = &creatorID
}
if gotTimestamp {
intTimestamp := int64(timestamp)
if float64(intTimestamp) != timestamp {
return NewInvalidSignatureError("Field optional.timestamp is not is not an integer")
}
s.UntrustedTimestamp = &intTimestamp
}

var t string
var image, identity json.RawMessage
if err := ParanoidUnmarshalJSONObjectExactFields(critical, map[string]interface{}{
"type": &t,
"image": &image,
"identity": &identity,
}); err != nil {
return err
}
if t != cosignSignatureType {
return NewInvalidSignatureError(fmt.Sprintf("Unrecognized signature type %s", t))
}

var digestString string
if err := ParanoidUnmarshalJSONObjectExactFields(image, map[string]interface{}{
"docker-manifest-digest": &digestString,
}); err != nil {
return err
}
s.UntrustedDockerManifestDigest = digest.Digest(digestString)

return ParanoidUnmarshalJSONObjectExactFields(identity, map[string]interface{}{
"docker-reference": &s.UntrustedDockerReference,
})
}
199 changes: 199 additions & 0 deletions signature/internal/cosign_payload_test.go
@@ -0,0 +1,199 @@
package internal

import (
"encoding/json"
"testing"
"time"

"github.com/containers/image/v5/version"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

type mSI map[string]interface{} // To minimize typing the long name

// A short-hand way to get a JSON object field value or panic. No error handling done, we know
// what we are working with, a panic in a test is good enough, and fitting test cases on a single line
// is a priority.
func x(m mSI, fields ...string) mSI {
for _, field := range fields {
// Not .(mSI) because type assertion of an unnamed type to a named type always fails (the types
// are not "identical"), but the assignment is fine because they are "assignable".
m = m[field].(map[string]interface{})
}
return m
}

func TestNewUntrustedCosignPayload(t *testing.T) {
timeBefore := time.Now()
sig := NewUntrustedCosignPayload(TestImageManifestDigest, TestImageSignatureReference)
assert.Equal(t, TestImageManifestDigest, sig.UntrustedDockerManifestDigest)
assert.Equal(t, TestImageSignatureReference, sig.UntrustedDockerReference)
require.NotNil(t, sig.UntrustedCreatorID)
assert.Equal(t, "containers/image "+version.Version, *sig.UntrustedCreatorID)
require.NotNil(t, sig.UntrustedTimestamp)
timeAfter := time.Now()
assert.True(t, timeBefore.Unix() <= *sig.UntrustedTimestamp)
assert.True(t, *sig.UntrustedTimestamp <= timeAfter.Unix())
}

func TestUntrustedCosignPayloadMarshalJSON(t *testing.T) {
// Empty string values
s := NewUntrustedCosignPayload("", "_")
_, err := s.MarshalJSON()
assert.Error(t, err)
s = NewUntrustedCosignPayload("_", "")
_, err = s.MarshalJSON()
assert.Error(t, err)

// Success
// Use intermediate variables for these values so that we can take their addresses.
creatorID := "CREATOR"
timestamp := int64(1484683104)
for _, c := range []struct {
input UntrustedCosignPayload
expected string
}{
{
UntrustedCosignPayload{
UntrustedDockerManifestDigest: "digest!@#",
UntrustedDockerReference: "reference#@!",
UntrustedCreatorID: &creatorID,
UntrustedTimestamp: &timestamp,
},
"{\"critical\":{\"identity\":{\"docker-reference\":\"reference#@!\"},\"image\":{\"docker-manifest-digest\":\"digest!@#\"},\"type\":\"cosign container image signature\"},\"optional\":{\"creator\":\"CREATOR\",\"timestamp\":1484683104}}",
},
{
UntrustedCosignPayload{
UntrustedDockerManifestDigest: "digest!@#",
UntrustedDockerReference: "reference#@!",
},
"{\"critical\":{\"identity\":{\"docker-reference\":\"reference#@!\"},\"image\":{\"docker-manifest-digest\":\"digest!@#\"},\"type\":\"cosign container image signature\"},\"optional\":{}}",
},
} {
marshaled, err := c.input.MarshalJSON()
require.NoError(t, err)
assert.Equal(t, []byte(c.expected), marshaled)

// Also call MarshalJSON through the JSON package.
marshaled, err = json.Marshal(c.input)
assert.NoError(t, err)
assert.Equal(t, []byte(c.expected), marshaled)
}
}

// Return the result of modifying validJSON with fn
func modifiedUntrustedCosignPayloadJSON(t *testing.T, validJSON []byte, modifyFn func(mSI)) []byte {
var tmp mSI
err := json.Unmarshal(validJSON, &tmp)
require.NoError(t, err)

modifyFn(tmp)

modifiedJSON, err := json.Marshal(tmp)
require.NoError(t, err)
return modifiedJSON
}

// Verify that input can be unmarshaled as an untrustedCosignPayload.
func successfullyUnmarshalUntrustedCosignPayload(t *testing.T, input []byte) UntrustedCosignPayload {
var s UntrustedCosignPayload
err := json.Unmarshal(input, &s)
require.NoError(t, err, string(input))

return s
}

// Verify that input can't be unmarshaled as an untrustedCosignPayload.
func assertUnmarshalUntrustedCosignPayloadFails(t *testing.T, input []byte) {
var s UntrustedCosignPayload
err := json.Unmarshal(input, &s)
assert.Error(t, err, string(input))
}

func TestUntrustedCosignPayloadUnmarshalJSON(t *testing.T) {
// Invalid input. Note that json.Unmarshal is guaranteed to validate input before calling our
// UnmarshalJSON implementation; so test that first, then test our error handling for completeness.
assertUnmarshalUntrustedCosignPayloadFails(t, []byte("&"))
var s UntrustedCosignPayload
err := s.UnmarshalJSON([]byte("&"))
assert.Error(t, err)

// Not an object
assertUnmarshalUntrustedCosignPayloadFails(t, []byte("1"))

// Start with a valid JSON.
validSig := NewUntrustedCosignPayload("digest!@#", "reference#@!")
validJSON, err := validSig.MarshalJSON()
require.NoError(t, err)

// Success
s = successfullyUnmarshalUntrustedCosignPayload(t, validJSON)
assert.Equal(t, validSig, s)

// Various ways to corrupt the JSON
breakFns := []func(mSI){
// A top-level field is missing
func(v mSI) { delete(v, "critical") },
func(v mSI) { delete(v, "optional") },
// Extra top-level sub-object
func(v mSI) { v["unexpected"] = 1 },
// "critical" not an object
func(v mSI) { v["critical"] = 1 },
// "optional" not an object
func(v mSI) { v["optional"] = 1 },
// A field of "critical" is missing
func(v mSI) { delete(x(v, "critical"), "type") },
func(v mSI) { delete(x(v, "critical"), "image") },
func(v mSI) { delete(x(v, "critical"), "identity") },
// Extra field of "critical"
func(v mSI) { x(v, "critical")["unexpected"] = 1 },
// Invalid "type"
func(v mSI) { x(v, "critical")["type"] = 1 },
func(v mSI) { x(v, "critical")["type"] = "unexpected" },
// Invalid "image" object
func(v mSI) { x(v, "critical")["image"] = 1 },
func(v mSI) { delete(x(v, "critical", "image"), "docker-manifest-digest") },
func(v mSI) { x(v, "critical", "image")["unexpected"] = 1 },
// Invalid "docker-manifest-digest"
func(v mSI) { x(v, "critical", "image")["docker-manifest-digest"] = 1 },
// Invalid "identity" object
func(v mSI) { x(v, "critical")["identity"] = 1 },
func(v mSI) { delete(x(v, "critical", "identity"), "docker-reference") },
func(v mSI) { x(v, "critical", "identity")["unexpected"] = 1 },
// Invalid "docker-reference"
func(v mSI) { x(v, "critical", "identity")["docker-reference"] = 1 },
// Invalid "creator"
func(v mSI) { x(v, "optional")["creator"] = 1 },
// Invalid "timestamp"
func(v mSI) { x(v, "optional")["timestamp"] = "unexpected" },
func(v mSI) { x(v, "optional")["timestamp"] = 0.5 }, // Fractional input
}
for _, fn := range breakFns {
testJSON := modifiedUntrustedCosignPayloadJSON(t, validJSON, fn)
assertUnmarshalUntrustedCosignPayloadFails(t, testJSON)
}

// Modifications to unrecognized fields in "optional" are allowed and ignored
allowedModificationFns := []func(mSI){
// Add an optional field
func(v mSI) { x(v, "optional")["unexpected"] = 1 },
}
for _, fn := range allowedModificationFns {
testJSON := modifiedUntrustedCosignPayloadJSON(t, validJSON, fn)
s := successfullyUnmarshalUntrustedCosignPayload(t, testJSON)
assert.Equal(t, validSig, s)
}

// Optional fields can be missing
validSig = UntrustedCosignPayload{
UntrustedDockerManifestDigest: "digest!@#",
UntrustedDockerReference: "reference#@!",
UntrustedCreatorID: nil,
UntrustedTimestamp: nil,
}
validJSON, err = validSig.MarshalJSON()
require.NoError(t, err)
s = successfullyUnmarshalUntrustedCosignPayload(t, validJSON)
assert.Equal(t, validSig, s)
}
10 changes: 10 additions & 0 deletions signature/internal/fixtures_info_test.go
@@ -0,0 +1,10 @@
package internal

import "github.com/opencontainers/go-digest"

const (
// TestImageManifestDigest is the Docker manifest digest of "image.manifest.json"
TestImageManifestDigest = digest.Digest("sha256:20bf21ed457b390829cdbeec8795a7bea1626991fda603e0d01b4e7f60427e55")
// TestImageSignatureReference is the Docker image reference signed in "image.signature"
TestImageSignatureReference = "testing/manifest"
)

0 comments on commit ceacd85

Please sign in to comment.