Skip to content

Commit

Permalink
Add Cosign verification support
Browse files Browse the repository at this point in the history
type: cosignSigned, with the usual keyData/keyPath.
Fulcio/Rekor is not currently implemented.

NOTE: This only allows a single public key, not a keyring,
unlike simple signing. That seems problematic, there are
known users of that. But we can fix that later by adding
keyDirectory and the like.

NOTE: Cosign interoperability requires use of
signedIdentity: matchRepository. The fairly useful
signedIdentity: remapIdentity has no repository-match
functionality.

NOTE: Multi-arch images need to be signed by cosign
with --recursive to be accepted; c/image enforces
signatures per platform.

Signed-off-by: Miloslav Trmač <mitr@redhat.com>
  • Loading branch information
mtrmac committed Jul 8, 2022
1 parent bdb2613 commit 6475691
Show file tree
Hide file tree
Showing 34 changed files with 958 additions and 7 deletions.
37 changes: 35 additions & 2 deletions docs/containers-policy.json.5.md
Expand Up @@ -149,7 +149,7 @@ This requirement rejects every image, and every signature.

### `signedBy`

This requirement requires an image to be signed with an expected identity, or accepts a signature if it is using an expected identity and key.
This requirement requires an image to be signed using “simple signing” with an expected identity, or accepts a signature if it is using an expected identity and key.

```js
{
Expand Down Expand Up @@ -236,6 +236,24 @@ used with `exactReference` or `exactRepository`.

<!-- ### `signedBaseLayer` -->


### `cosignSigned`

This requirement requires an image to be signed using a Cosign signature with an expected identity and key.

```js
{
"type": "cosignSigned",
"keyPath": "/path/to/local/keyring/file",
"keyData": "base64-encoded-keyring-data",
"signedIdentity": identity_requirement
}
```
Exactly one of `keyPath` and `keyData` must be present, containing a Cosign public key. Only signatures made by this key is accepted.

The `signedIdentity` field has the same semantics as in the `signedBy` requirement described above.
Note that `cosign`-created signatures only contain a repository, so only `matchRepository` and `exactRepository` can be used to accept them (and that does not protect against substitution of a signed image with an unexpected tag).

## Examples

It is *strongly* recommended to set the `default` policy to `reject`, and then
Expand All @@ -257,7 +275,22 @@ selectively allow individual transports and scopes as desired.
form, with the explicit /library/, must be used. */
"docker.io/library/busybox": [{"type": "insecureAcceptAnything"}],
/* Allow installing images from all subdomains */
"*.temporary-project.example.com": [{"type": "insecureAcceptAnything"}]
"*.temporary-project.example.com": [{"type": "insecureAcceptAnything"}],
/* A Cosign-signed repository */
"hostname:5000/myns/cosign-signed-with-full-references": [
{
"type": "cosignSigned",
"keyPath": "/path/to/cosign-pubkey.key"
}
],
/* A Cosign-signed repository, accepts signatures by /usr/bin/cosign */
"hostname:5000/myns/cosign-signed-risky": [
{
"type": "cosignSigned",
"keyPath": "/path/to/cosign-pubkey.key",
"signedIdentity": {"type": "matchRepository"}
}
]
/* Other docker: images use the global default policy and are rejected */
},
"dir": {
Expand Down
1 change: 0 additions & 1 deletion go.mod
Expand Up @@ -37,7 +37,6 @@ require (
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211

)

require (
Expand Down
4 changes: 4 additions & 0 deletions signature/fixtures/cosign.pub
@@ -0,0 +1,4 @@
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEFNLqFhf4fiN6o/glAuYnq2jYUeL0
vRuLu/z39pmbVwS9ff5AYnlwaP9sxREajdLY9ynM6G1sy6AAmb7Z63TsLg==
-----END PUBLIC KEY-----
1 change: 1 addition & 0 deletions signature/fixtures/dir-img-cosign-mixed/manifest.json
1 change: 1 addition & 0 deletions signature/fixtures/dir-img-cosign-mixed/signature-1
1 change: 1 addition & 0 deletions signature/fixtures/dir-img-cosign-mixed/signature-2
17 changes: 17 additions & 0 deletions signature/fixtures/dir-img-cosign-modified-manifest/manifest.json
@@ -0,0 +1,17 @@
{
"schemaVersion": 2,
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
"config": {
"mediaType": "application/vnd.docker.container.image.v1+json",
"size": 1512,
"digest": "sha256:961769676411f082461f9ef46626dd7a2d1e2b2a38e6a44364bcbecf51e66dd4"
},
"layers": [
{
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
"size": 2896510,
"digest": "sha256:9d16cba9fb961d1aafec9542f2bf7cb64acfc55245f9e4eb5abecd4cdc38d749"
}
],
"extra": "this manifest has been modified"
}
1 change: 1 addition & 0 deletions signature/fixtures/dir-img-cosign-no-manifest/signature-1
Binary file not shown.
1 change: 1 addition & 0 deletions signature/fixtures/dir-img-cosign-valid-2/manifest.json
1 change: 1 addition & 0 deletions signature/fixtures/dir-img-cosign-valid-2/signature-1
Binary file not shown.
@@ -0,0 +1 @@
{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":1512,"digest":"sha256:961769676411f082461f9ef46626dd7a2d1e2b2a38e6a44364bcbecf51e66dd4"},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":2896510,"digest":"sha256:9d16cba9fb961d1aafec9542f2bf7cb64acfc55245f9e4eb5abecd4cdc38d749"}]}
Binary file not shown.
1 change: 1 addition & 0 deletions signature/fixtures/dir-img-cosign-valid/manifest.json
@@ -0,0 +1 @@
{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":1512,"digest":"sha256:961769676411f082461f9ef46626dd7a2d1e2b2a38e6a44364bcbecf51e66dd4"},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":2896510,"digest":"sha256:9d16cba9fb961d1aafec9542f2bf7cb64acfc55245f9e4eb5abecd4cdc38d749"}]}
Binary file added signature/fixtures/dir-img-cosign-valid/signature-1
Binary file not shown.
15 changes: 15 additions & 0 deletions signature/fixtures/policy.json
Expand Up @@ -132,6 +132,21 @@
"dockerReference": "registry.access.redhat.com/rhel7/rhel:latest"
}
}
],
"example.com/cosign/key-data-example": [
{
"type": "cosignSigned",
"keyData": "bm9uc2Vuc2U="
}
],
"example.com/cosign/key-Path-example": [
{
"type": "cosignSigned",
"keyPath": "/keys/public-key",
"signedIdentity": {
"type": "matchRepository"
}
}
]
}
}
Expand Down
Binary file added signature/fixtures/unknown-cosign-key.signature
Binary file not shown.
50 changes: 49 additions & 1 deletion signature/internal/cosign_payload.go
Expand Up @@ -2,17 +2,21 @@ package internal

import (
"bytes"
"crypto"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"

"github.com/containers/image/v5/version"
digest "github.com/opencontainers/go-digest"
cosignSignature "github.com/sigstore/sigstore/pkg/signature"
)

const (
cosignSignatureType = "cosign container image signature"
cosignSignatureType = "cosign container image signature"
cosignHarcodedHashAlgorithm = crypto.SHA256
)

// UntrustedCosignPayload is a parsed content of a Cosign signature payload (not the full signature)
Expand Down Expand Up @@ -151,3 +155,47 @@ func (s *UntrustedCosignPayload) strictUnmarshalJSON(data []byte) error {
"docker-reference": &s.UntrustedDockerReference,
})
}

// CosignPayloadAcceptanceRules specifies how to decide whether an untrusted payload is acceptable.
// We centralize the actual parsing and data extraction in VerifyCosignPayload; this supplies
// the policy. We use an object instead of supplying func parameters to verifyAndExtractSignature
// because the functions have the same or similar types, so there is a risk of exchanging the functions;
// named members of this struct are more explicit.
type CosignPayloadAcceptanceRules struct {
ValidateSignedDockerReference func(string) error
ValidateSignedDockerManifestDigest func(digest.Digest) error
}

// VerifyCosignPayload verifies unverifiedBase64Signature of unverifiedPayload was correctly created by publicKey, and that its principal components
// match expected values, both as specified by rules, and returns it.
// We return an *UntrustedCosignPayload, although nothing actually uses it,
// just to double-check against stupid typos.
func VerifyCosignPayload(publicKey crypto.PublicKey, unverifiedPayload []byte, unverifiedBase64Signature string, rules CosignPayloadAcceptanceRules) (*UntrustedCosignPayload, error) {
verifier, err := cosignSignature.LoadVerifier(publicKey, cosignHarcodedHashAlgorithm)
if err != nil {
return nil, fmt.Errorf("creating verifier: %w", err)
}

unverifiedSignature, err := base64.StdEncoding.DecodeString(unverifiedBase64Signature)
if err != nil {
return nil, NewInvalidSignatureError(fmt.Sprintf("base64 decoding: %v", err))
}
// github.com/sigstore/cosign/pkg/cosign.verifyOCISignature uses signatureoptions.WithContext(),
// which seems to be not used by anything. So we don’t bother.
if err := verifier.VerifySignature(bytes.NewReader(unverifiedSignature), bytes.NewReader(unverifiedPayload)); err != nil {
return nil, NewInvalidSignatureError(fmt.Sprintf("cryptographic signature verification failed: %v", err))
}

var unmatchedPayload UntrustedCosignPayload
if err := json.Unmarshal(unverifiedPayload, &unmatchedPayload); err != nil {
return nil, NewInvalidSignatureError(err.Error())
}
if err := rules.ValidateSignedDockerManifestDigest(unmatchedPayload.UntrustedDockerManifestDigest); err != nil {
return nil, err
}
if err := rules.ValidateSignedDockerReference(unmatchedPayload.UntrustedDockerReference); err != nil {
return nil, err
}
// CosignPayloadAcceptanceRules have accepted this value.
return &unmatchedPayload, nil
}
135 changes: 135 additions & 0 deletions signature/internal/cosign_payload_test.go
@@ -1,11 +1,17 @@
package internal

import (
"encoding/base64"
"encoding/json"
"errors"
"os"
"testing"
"time"

"github.com/containers/image/v5/internal/signature"
"github.com/containers/image/v5/version"
digest "github.com/opencontainers/go-digest"
"github.com/sigstore/sigstore/pkg/cryptoutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -206,3 +212,132 @@ func TestUntrustedCosignPayloadUnmarshalJSON(t *testing.T) {
s = successfullyUnmarshalUntrustedCosignPayload(t, validJSON)
assert.Equal(t, validSig, s)
}

func TestVerifyCosignPayload(t *testing.T) {
publicKeyPEM, err := os.ReadFile("./testdata/cosign.pub")
require.NoError(t, err)
publicKey, err := cryptoutils.UnmarshalPEMToPublicKey(publicKeyPEM)
require.NoError(t, err)

type acceptanceData struct {
signedDockerReference string
signedDockerManifestDigest digest.Digest
}
var wanted, recorded acceptanceData
// recordingRules are a plausible CosignPayloadAcceptanceRules implementations, but equally
// importantly record that we are passing the correct values to the rule callbacks.
recordingRules := CosignPayloadAcceptanceRules{
ValidateSignedDockerReference: func(signedDockerReference string) error {
recorded.signedDockerReference = signedDockerReference
if signedDockerReference != wanted.signedDockerReference {
return errors.New("signedDockerReference mismatch")
}
return nil
},
ValidateSignedDockerManifestDigest: func(signedDockerManifestDigest digest.Digest) error {
recorded.signedDockerManifestDigest = signedDockerManifestDigest
if signedDockerManifestDigest != wanted.signedDockerManifestDigest {
return errors.New("signedDockerManifestDigest mismatch")
}
return nil
},
}

sigBlob, err := os.ReadFile("./testdata/valid.signature")
require.NoError(t, err)
genericSig, err := signature.FromBlob(sigBlob)
require.NoError(t, err)
cosignSig, ok := genericSig.(signature.Cosign)
require.True(t, ok)
cryptoBase64Sig, ok := cosignSig.UntrustedAnnotations()[signature.CosignSignatureAnnotationKey]
require.True(t, ok)
signatureData := acceptanceData{
signedDockerReference: TestCosignSignatureReference,
signedDockerManifestDigest: TestCosignManifestDigest,
}

// Successful verification
wanted = signatureData
recorded = acceptanceData{}
res, err := VerifyCosignPayload(publicKey, cosignSig.UntrustedPayload(), cryptoBase64Sig, recordingRules)
require.NoError(t, err)
assert.Equal(t, res, &UntrustedCosignPayload{
UntrustedDockerManifestDigest: TestCosignManifestDigest,
UntrustedDockerReference: TestCosignSignatureReference,
UntrustedCreatorID: nil,
UntrustedTimestamp: nil,
})
assert.Equal(t, signatureData, recorded)

// For extra paranoia, test that we return a nil signature object on error.

// Invalid verifier
recorded = acceptanceData{}
invalidPublicKey := struct{}{} // crypto.PublicKey is, for some reason, just an interface{}, so this is acceptable.
res, err = VerifyCosignPayload(invalidPublicKey, cosignSig.UntrustedPayload(), cryptoBase64Sig, recordingRules)
assert.Error(t, err)
assert.Nil(t, res)
assert.Equal(t, acceptanceData{}, recorded)

// Invalid base64 encoding
for _, invalidBase64Sig := range []string{
"&", // Invalid base64 characters
cryptoBase64Sig + "=", // Extra padding
cryptoBase64Sig[:len(cryptoBase64Sig)-1], // Truncated base64 data
} {
recorded = acceptanceData{}
res, err = VerifyCosignPayload(publicKey, cosignSig.UntrustedPayload(), invalidBase64Sig, recordingRules)
assert.Error(t, err)
assert.Nil(t, res)
assert.Equal(t, acceptanceData{}, recorded)
}

// Invalid signature
validSignatureBytes, err := base64.StdEncoding.DecodeString(cryptoBase64Sig)
require.NoError(t, err)
for _, invalidSig := range [][]byte{
{}, // Empty signature
[]byte("invalid signature"),
append(validSignatureBytes, validSignatureBytes...),
} {
recorded = acceptanceData{}
res, err = VerifyCosignPayload(publicKey, cosignSig.UntrustedPayload(), base64.StdEncoding.EncodeToString(invalidSig), recordingRules)
assert.Error(t, err)
assert.Nil(t, res)
assert.Equal(t, acceptanceData{}, recorded)
}

// Valid signature of non-JSON
recorded = acceptanceData{}
res, err = VerifyCosignPayload(publicKey, []byte("&"), "MEUCIARnnxZQPALBfqkB4aNAYXad79Qs6VehcrgIeZ8p7I2FAiEAzq2HXwXlz1iJeh+ucUR3L0zpjynQk6Rk0+/gXYp49RU=", recordingRules)
assert.Error(t, err)
assert.Nil(t, res)
assert.Equal(t, acceptanceData{}, recorded)

// Valid signature of an unacceptable JSON
recorded = acceptanceData{}
res, err = VerifyCosignPayload(publicKey, []byte("{}"), "MEUCIQDkySOBGxastVP0+koTA33NH5hXjwosFau4rxTPN6g48QIgb7eWKkGqfEpHMM3aT4xiqyP/170jEkdFuciuwN4mux4=", recordingRules)
assert.Error(t, err)
assert.Nil(t, res)
assert.Equal(t, acceptanceData{}, recorded)

// Valid signature with a wrong manifest digest: asked for signedDockerManifestDigest
wanted = signatureData
wanted.signedDockerManifestDigest = "invalid digest"
recorded = acceptanceData{}
res, err = VerifyCosignPayload(publicKey, cosignSig.UntrustedPayload(), cryptoBase64Sig, recordingRules)
assert.Error(t, err)
assert.Nil(t, res)
assert.Equal(t, acceptanceData{
signedDockerManifestDigest: signatureData.signedDockerManifestDigest,
}, recorded)

// Valid signature with a wrong image reference
wanted = signatureData
wanted.signedDockerReference = "unexpected docker reference"
recorded = acceptanceData{}
res, err = VerifyCosignPayload(publicKey, cosignSig.UntrustedPayload(), cryptoBase64Sig, recordingRules)
assert.Error(t, err)
assert.Nil(t, res)
assert.Equal(t, signatureData, recorded)
}
5 changes: 5 additions & 0 deletions signature/internal/fixtures_info_test.go
Expand Up @@ -7,4 +7,9 @@ const (
TestImageManifestDigest = digest.Digest("sha256:20bf21ed457b390829cdbeec8795a7bea1626991fda603e0d01b4e7f60427e55")
// TestImageSignatureReference is the Docker image reference signed in "image.signature"
TestImageSignatureReference = "testing/manifest"

// TestCosignManifestDigest is the manifest digest of "valid.signature"
TestCosignManifestDigest = digest.Digest("sha256:634a8f35b5f16dcf4aaa0822adc0b1964bb786fca12f6831de8ddc45e5986a00")
// TestCosignSignatureReference is the Docker reference signed in "valid.signature"
TestCosignSignatureReference = "192.168.64.2:5000/cosign-signed-single-sample"
)
1 change: 1 addition & 0 deletions signature/internal/testdata/cosign.pub
1 change: 1 addition & 0 deletions signature/internal/testdata/valid.signature

0 comments on commit 6475691

Please sign in to comment.