Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add signature.untrustedCosignPayload, with JSON marshaling
The code and tests are basically copy&pasted from simple signing. Signed-off-by: Miloslav Trmač <mitr@redhat.com>
- Loading branch information
Showing
3 changed files
with
369 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: ×tamp, | ||
} | ||
} | ||
|
||
// 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 ×tamp | ||
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, | ||
}) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: ×tamp, | ||
}, | ||
"{\"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) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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" | ||
) |