Skip to content

Commit

Permalink
Add support for reading and writing Cosign attachments, incl. signatures
Browse files Browse the repository at this point in the history
NOTE design decisions:
- We can read Cosign data from lookaside
- We ONLY write Cosign data to Cosign attachments, never
  to lookaside; because lookaside is set up by default, that
  would be too confusing.
- We ONLY use Cosign attachments at all if the user opts in
  via registries.d.

  One concern is performance impact of the extra round-trip
  for large-scale operations like (skopeo sync).

  Short-term, a much more worrying is the risk that we probably
  have the "is this failure just a missing atachment manifest,
  or a real failure reading it?" heuristic wrong, so without an
  opt-in, _all_ image reads are going to fail.  This might eventually
  go away after more testing.

Signed-off-by: Miloslav Trmač <mitr@redhat.com>
  • Loading branch information
mtrmac committed Jul 7, 2022
1 parent a48ed60 commit 1e5089f
Show file tree
Hide file tree
Showing 3 changed files with 302 additions and 15 deletions.
72 changes: 72 additions & 0 deletions docker/docker_client.go
Expand Up @@ -25,9 +25,12 @@ import (
"github.com/containers/image/v5/types"
"github.com/containers/image/v5/version"
"github.com/containers/storage/pkg/homedir"
"github.com/docker/distribution/registry/api/errcode"
v2 "github.com/docker/distribution/registry/api/v2"
clientLib "github.com/docker/distribution/registry/client"
"github.com/docker/go-connections/tlsconfig"
digest "github.com/opencontainers/go-digest"
imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1"
perrors "github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
Expand Down Expand Up @@ -900,6 +903,70 @@ func (c *dockerClient) getBlob(ctx context.Context, ref dockerReference, info ty
return res.Body, getBlobSize(res), nil
}

// getOCIDescriptorContents returns the contents a blob spcified by descriptor in ref, which must fit within limit.
func (c *dockerClient) getOCIDescriptorContents(ctx context.Context, ref dockerReference, desc imgspecv1.Descriptor, maxSize int, cache types.BlobInfoCache) ([]byte, error) {
// Note that this copies all kinds of attachments: attestations, and whatever else is there,
// not just signatures. We leave the signature consumers to decide based on the MIME type.
reader, _, err := c.getBlob(ctx, ref, manifest.BlobInfoFromOCI1Descriptor(desc), cache)
if err != nil {
return nil, err
}
defer reader.Close()
payload, err := iolimits.ReadAtMost(reader, iolimits.MaxSignatureBodySize)
if err != nil {
return nil, fmt.Errorf("reading blob %s in %s: %w", desc.Digest.String(), ref.ref.Name(), err)
}
return payload, nil
}

// isManifestUnknownError returns true iff err from client.HandleErrorResponse is a “manifest unknown” error.
func isManifestUnknownError(err error) bool {
errors, ok := err.(errcode.Errors)
if !ok || len(errors) == 0 {
return false
}
err = errors[0]
ec, ok := err.(errcode.ErrorCoder)
if !ok {
return false
}
return ec.ErrorCode() == v2.ErrorCodeManifestUnknown
}

// getCosignAttachmentManifest loads and parses the manifest for Cosign attachments for
// digest in ref.
// It returns (nil, nil) if the manifest does not exist.
func (c *dockerClient) getCosignAttachmentManifest(ctx context.Context, ref dockerReference, digest digest.Digest) (*manifest.OCI1, error) {
tag := cosignAttachmentTag(digest)
cosignRef, err := reference.WithTag(reference.TrimNamed(ref.ref), tag)
if err != nil {
return nil, err
}
logrus.Debugf("Looking for Cosign attachments in %s", cosignRef.String())
manifestBlob, mimeType, err := c.fetchManifest(ctx, ref, tag)
if err != nil {
// FIXME: Are we going to need better heuristics??
// This alone is probably a good enough reason for Cosign to be opt-in only,
// otherwise we would just break ordinary copies.
if isManifestUnknownError(err) {
logrus.Debugf("Fetching Cosign attachment manifest failed, assuming it does not exist: %v", err)
return nil, nil
}
logrus.Debugf("Fetching Cosign attachment manifest failed: %v", err)
return nil, err
}
if mimeType != imgspecv1.MediaTypeImageManifest {
// FIXME: Try anyway??
return nil, fmt.Errorf("unexpected MIME type for Cosign attachment manifest %s: %q",
cosignRef.String(), mimeType)
}
res, err := manifest.OCI1FromManifest(manifestBlob)
if err != nil {
return nil, fmt.Errorf("parsing manifest %s: %w", cosignRef.String(), err)
}
return res, nil
}

// getExtensionsSignatures returns signatures from the X-Registry-Supports-Signatures API extension,
// using the original data structures.
func (c *dockerClient) getExtensionsSignatures(ctx context.Context, ref dockerReference, manifestDigest digest.Digest) (*extensionSignatureList, error) {
Expand All @@ -925,3 +992,8 @@ func (c *dockerClient) getExtensionsSignatures(ctx context.Context, ref dockerRe
}
return &parsedBody, nil
}

// cosignAttachmentTag returns a Cosign attachment tag for the specified digest.
func cosignAttachmentTag(d digest.Digest) string {
return strings.Replace(d.String(), ":", "-", 1) + ".sig"
}
186 changes: 173 additions & 13 deletions docker/docker_image_dest.go
Expand Up @@ -18,12 +18,14 @@ import (
"github.com/containers/image/v5/internal/blobinfocache"
"github.com/containers/image/v5/internal/imagedestination/impl"
"github.com/containers/image/v5/internal/imagedestination/stubs"
"github.com/containers/image/v5/internal/iolimits"
"github.com/containers/image/v5/internal/private"
"github.com/containers/image/v5/internal/putblobdigest"
"github.com/containers/image/v5/internal/signature"
"github.com/containers/image/v5/internal/streamdigest"
"github.com/containers/image/v5/internal/uploadreader"
"github.com/containers/image/v5/manifest"
"github.com/containers/image/v5/pkg/blobinfocache/none"
"github.com/containers/image/v5/types"
"github.com/docker/distribution/registry/api/errcode"
v2 "github.com/docker/distribution/registry/api/v2"
Expand Down Expand Up @@ -521,10 +523,6 @@ func isManifestInvalidError(err error) bool {
// (when the primary manifest is a manifest list); this should always be nil if the primary manifest is not a manifest list.
// MUST be called after PutManifest (signatures may reference manifest contents).
func (d *dockerImageDestination) PutSignaturesWithFormat(ctx context.Context, signatures []signature.Signature, instanceDigest *digest.Digest) error {
// Do not fail if we don’t really need to support signatures.
if len(signatures) == 0 {
return nil
}
if instanceDigest == nil {
if d.manifestDigest == "" {
// This shouldn’t happen, ImageDestination users are required to call PutManifest before PutSignatures
Expand All @@ -533,17 +531,45 @@ func (d *dockerImageDestination) PutSignaturesWithFormat(ctx context.Context, si
instanceDigest = &d.manifestDigest
}

if err := d.c.detectProperties(ctx); err != nil {
return err
cosignSignatures := []signature.Cosign{}
otherSignatures := []signature.Signature{}
for _, sig := range signatures {
if cosignSig, ok := sig.(signature.Cosign); ok {
cosignSignatures = append(cosignSignatures, cosignSig)
} else {
otherSignatures = append(otherSignatures, sig)
}
}
switch {
case d.c.supportsSignatures:
return d.putSignaturesToAPIExtension(ctx, signatures, *instanceDigest)
case d.c.signatureBase != nil:
return d.putSignaturesToLookaside(signatures, *instanceDigest)
default:
return errors.New("Internal error: X-Registry-Supports-Signatures extension not supported, and lookaside should not be empty configuration")

// Only write Cosign signatures to cosign attachments. We _could_ store them to lookaside
// instead, but that would probably be rather surprising.
// FIXME: So should we enable cosign in all cases? Or write in all cases, but opt-in to read?

if len(cosignSignatures) != 0 {
if err := d.putSignaturesToCosignAttachments(ctx, cosignSignatures, *instanceDigest); err != nil {
return err
}
}

if len(otherSignatures) != 0 {
if err := d.c.detectProperties(ctx); err != nil {
return err
}
switch {
case d.c.supportsSignatures:
if err := d.putSignaturesToAPIExtension(ctx, signatures, *instanceDigest); err != nil {
return err
}
case d.c.signatureBase != nil:
if err := d.putSignaturesToLookaside(signatures, *instanceDigest); err != nil {
return err
}
default:
return errors.New("Internal error: X-Registry-Supports-Signatures extension not supported, and lookaside should not be empty configuration")
}
}

return nil
}

// putSignaturesToLookaside implements PutSignaturesWithFormat() from the lookaside location configured in s.c.signatureBase,
Expand Down Expand Up @@ -611,6 +637,140 @@ func (d *dockerImageDestination) putOneSignature(url *url.URL, sig signature.Sig
}
}

func (d *dockerImageDestination) putSignaturesToCosignAttachments(ctx context.Context, signatures []signature.Cosign, manifestDigest digest.Digest) error {
if !d.c.useCosignAttachments {
return errors.New("writing Cosign attachments is disabled by configuration")
}

ociManifest, err := d.c.getCosignAttachmentManifest(ctx, d.ref, manifestDigest)
if err != nil {
return nil
}
var ociConfig imgspecv1.Image // Most fields empty by default
if ociManifest == nil {
ociManifest = manifest.OCI1FromComponents(imgspecv1.Descriptor{
MediaType: imgspecv1.MediaTypeImageConfig,
Digest: "", // We will fill this in later.
Size: 0,
}, nil)
} else {
logrus.Debugf("Fetching Cosign attachment config %s", ociManifest.Config.Digest.String())
// We don’t benefit from a real BlobInfoCache here because we never try to reuse/mount configs.
configBlob, err := d.c.getOCIDescriptorContents(ctx, d.ref, ociManifest.Config, iolimits.MaxConfigBodySize,
none.NoCache)
if err != nil {
return err
}
if err := json.Unmarshal(configBlob, &ociConfig); err != nil {
return fmt.Errorf("parsing Cosign attachment config %s in %s: %w", ociManifest.Config.Digest.String(),
d.ref.ref.Name(), err)
}
}

for _, sig := range signatures {
mimeType := sig.UntrustedMIMEType()
payloadBlob := sig.UntrustedPayload()
annotations := sig.UntrustedAnnotations()

alreadyOnRegistry := false
for _, layer := range ociManifest.Layers {
if layerMatchesCosignSignature(layer, mimeType, payloadBlob, annotations) {
logrus.Debugf("Signature with digest %s already exists on the registry", layer.Digest.String())
alreadyOnRegistry = true
break
}
}
if alreadyOnRegistry {
continue
}

// We don’t benefit from a real BlobInfoCache here because we never try to reuse/mount attachment payloads.
// That might eventually need to change if payloads grow to be not just signatures, but something
// significantly large.
sigDesc, err := d.putBlobBytesAsOCI(ctx, payloadBlob, mimeType, private.PutBlobOptions{
Cache: none.NoCache,
IsConfig: false,
EmptyLayer: false,
LayerIndex: nil,
})
if err != nil {
return err
}
sigDesc.Annotations = annotations
ociManifest.Layers = append(ociManifest.Layers, sigDesc)
ociConfig.RootFS.DiffIDs = append(ociConfig.RootFS.DiffIDs, sigDesc.Digest)
logrus.Debugf("Adding new signature, digest %s", sigDesc.Digest.String())
}

configBlob, err := json.Marshal(ociConfig)
if err != nil {
return err
}
logrus.Debugf("Uploading updated Cosign attachment config")
// We don’t benefit from a real BlobInfoCache here because we never try to reuse/mount configs.
configDesc, err := d.putBlobBytesAsOCI(ctx, configBlob, imgspecv1.MediaTypeImageConfig, private.PutBlobOptions{
Cache: none.NoCache,
IsConfig: true,
EmptyLayer: false,
LayerIndex: nil,
})
if err != nil {
return nil
}
ociManifest.Config = configDesc

manifestBlob, err := ociManifest.Serialize()
if err != nil {
return nil
}
logrus.Debugf("Uploading Cosign attachment manifest")
return d.uploadManifest(ctx, manifestBlob, cosignAttachmentTag(manifestDigest))
}

func layerMatchesCosignSignature(layer imgspecv1.Descriptor, mimeType string,
payloadBlob []byte, annotations map[string]string) bool {
if layer.MediaType != mimeType ||
layer.Size != int64(len(payloadBlob)) ||
// This is not quite correct, we should use the layer’s digest algorithm.
// But right now we don’t want to deal with corner cases like bad digest formats
// or unavailable algorithms; in the worst case we end up with duplicate signature
// entries.
layer.Digest.String() != digest.FromBytes(payloadBlob).String() {
return false
}
if len(layer.Annotations) != len(annotations) {
return false
}
for k, v1 := range layer.Annotations {
if v2, ok := annotations[k]; !ok || v1 != v2 {
return false
}
}
// All annotations in layer exist in sig, and the number of annotations is the same, so all annotations
// in sig also exist in layer.
return true
}

// putBlobBytesAsOCI uploads a blob with the specified contents, and returns an appropriate
// OCI descriptior.
func (d *dockerImageDestination) putBlobBytesAsOCI(ctx context.Context, contents []byte, mimeType string, options private.PutBlobOptions) (imgspecv1.Descriptor, error) {
blobDigest := digest.FromBytes(contents)
info, err := d.PutBlobWithOptions(ctx, bytes.NewReader(contents),
types.BlobInfo{
Digest: blobDigest,
Size: int64(len(contents)),
MediaType: mimeType,
}, options)
if err != nil {
return imgspecv1.Descriptor{}, fmt.Errorf("writing blob %s: %w", blobDigest.String(), err)
}
return imgspecv1.Descriptor{
MediaType: mimeType,
Digest: info.Digest,
Size: info.Size,
}, nil
}

// deleteOneSignature deletes a signature from url, if it exists.
// If it successfully determines that the signature does not exist, returns (true, nil)
// NOTE: Keep this in sync with docs/signature-protocols.md!
Expand Down

0 comments on commit 1e5089f

Please sign in to comment.