From 1e5089fe0ad48b90fb6f5314d5ab59ec77bfb587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miloslav=20Trma=C4=8D?= Date: Wed, 6 Jul 2022 02:04:48 +0200 Subject: [PATCH] Add support for reading and writing Cosign attachments, incl. signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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č --- docker/docker_client.go | 72 ++++++++++++++ docker/docker_image_dest.go | 186 +++++++++++++++++++++++++++++++++--- docker/docker_image_src.go | 59 +++++++++++- 3 files changed, 302 insertions(+), 15 deletions(-) diff --git a/docker/docker_client.go b/docker/docker_client.go index 6b47f8cda..00a71310a 100644 --- a/docker/docker_client.go +++ b/docker/docker_client.go @@ -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" ) @@ -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) { @@ -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" +} diff --git a/docker/docker_image_dest.go b/docker/docker_image_dest.go index f1e145b2a..3e1b7f36d 100644 --- a/docker/docker_image_dest.go +++ b/docker/docker_image_dest.go @@ -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" @@ -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 @@ -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, @@ -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! diff --git a/docker/docker_image_src.go b/docker/docker_image_src.go index fe88fb36f..c96c8e192 100644 --- a/docker/docker_image_src.go +++ b/docker/docker_image_src.go @@ -21,6 +21,7 @@ import ( "github.com/containers/image/v5/internal/private" "github.com/containers/image/v5/internal/signature" "github.com/containers/image/v5/manifest" + "github.com/containers/image/v5/pkg/blobinfocache/none" "github.com/containers/image/v5/pkg/sysregistriesv2" "github.com/containers/image/v5/types" digest "github.com/opencontainers/go-digest" @@ -396,14 +397,30 @@ func (s *dockerImageSource) GetSignaturesWithFormat(ctx context.Context, instanc if err := s.c.detectProperties(ctx); err != nil { return nil, err } + var res []signature.Signature switch { case s.c.supportsSignatures: - return s.getSignaturesFromAPIExtension(ctx, instanceDigest) + sigs, err := s.getSignaturesFromAPIExtension(ctx, instanceDigest) + if err != nil { + return nil, err + } + res = append(res, sigs...) case s.c.signatureBase != nil: - return s.getSignaturesFromLookaside(ctx, instanceDigest) + sigs, err := s.getSignaturesFromLookaside(ctx, instanceDigest) + if err != nil { + return nil, err + } + res = append(res, sigs...) default: return nil, errors.New("Internal error: X-Registry-Supports-Signatures extension not supported, and lookaside should not be empty configuration") } + + cosignSigs, err := s.getSignaturesFromCosignAttachments(ctx, instanceDigest) + if err != nil { + return nil, err + } + res = append(res, cosignSigs...) + return res, nil } // manifestDigest returns a digest of the manifest, from instanceDigest if non-nil; or from the supplied reference, @@ -520,6 +537,44 @@ func (s *dockerImageSource) getSignaturesFromAPIExtension(ctx context.Context, i return sigs, nil } +func (s *dockerImageSource) getSignaturesFromCosignAttachments(ctx context.Context, instanceDigest *digest.Digest) ([]signature.Signature, error) { + if !s.c.useCosignAttachments { + logrus.Debugf("Not looking for Cosign attachments: disabled by configuration") + return nil, nil + } + + manifestDigest, err := s.manifestDigest(ctx, instanceDigest) + if err != nil { + return nil, err + } + + ociManifest, err := s.c.getCosignAttachmentManifest(ctx, s.physicalRef, manifestDigest) + if err != nil { + return nil, err + } + if ociManifest == nil { + return nil, nil + } + + logrus.Debugf("Found a Cosign attachment manifest with %d layers", len(ociManifest.Layers)) + res := []signature.Signature{} + for layerIndex, layer := range ociManifest.Layers { + // 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. + logrus.Debugf("Fetching Cosign attachment %d/%d: %s", layerIndex+1, len(ociManifest.Layers), layer.Digest.String()) + // 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. + payload, err := s.c.getOCIDescriptorContents(ctx, s.physicalRef, layer, iolimits.MaxSignatureBodySize, + none.NoCache) + if err != nil { + return nil, err + } + res = append(res, signature.CosignFromComponents(layer.MediaType, payload, layer.Annotations)) + } + return res, nil +} + // deleteImage deletes the named image from the registry, if supported. func deleteImage(ctx context.Context, sys *types.SystemContext, ref dockerReference) error { registryConfig, err := loadRegistryConfiguration(sys)