Browse code

image: pull/load/save attestation manifest and signatures with image

Updates docker pull to pull related attestation manifest and
any signatures for that manifest in cosign referrer objects.

These objects are transferred with the image when running
docker save and docker load and can be used to identify
the image in future updates.

Push is not updated atm as the currect push semantics
in containerd mode do not have correct immutability
guaranteed and don't work with image indexes.

Signed-off-by: Tonis Tiigi <tonistiigi@gmail.com>

Tonis Tiigi authored on 2025/09/17 04:53:40
Showing 6 changed files
... ...
@@ -2,6 +2,7 @@ package containerd
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"encoding/json"
5 6
 	"fmt"
6 7
 	"io"
7 8
 	"strings"
... ...
@@ -20,6 +21,7 @@ import (
20 20
 	"github.com/moby/moby/v2/daemon/images"
21 21
 	"github.com/moby/moby/v2/daemon/internal/streamformatter"
22 22
 	"github.com/moby/moby/v2/errdefs"
23
+	"github.com/opencontainers/go-digest"
23 24
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
24 25
 	"github.com/pkg/errors"
25 26
 )
... ...
@@ -34,6 +36,7 @@ import (
34 34
 func (i *ImageService) ExportImage(ctx context.Context, names []string, platformList []ocispec.Platform, outStream io.Writer) error {
35 35
 	// Get the platform matcher for the requested platforms (matches all platforms if none specified)
36 36
 	pm := matchAnyWithPreference(i.hostPlatformMatcher(), platformList)
37
+	referrers := newReferrersForExport(i.content)
37 38
 
38 39
 	opts := []archive.ExportOpt{
39 40
 		archive.WithSkipNonDistributableBlobs(),
... ...
@@ -51,6 +54,7 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, platform
51 51
 		// Importing the same archive into containerd, will not restrict the platforms.
52 52
 		archive.WithPlatform(pm),
53 53
 		archive.WithSkipMissing(i.content),
54
+		archive.WithReferrersProvider(referrers),
54 55
 	}
55 56
 
56 57
 	ctx, done, err := i.withLease(ctx, false)
... ...
@@ -198,6 +202,8 @@ func (i *ImageService) ExportImage(ctx context.Context, names []string, platform
198 198
 		}
199 199
 	}
200 200
 
201
+	opts = append(opts, archive.WithReferrersProvider(referrers))
202
+
201 203
 	return i.client.Export(ctx, outStream, opts...)
202 204
 }
203 205
 
... ...
@@ -249,6 +255,8 @@ func (i *ImageService) LoadImage(ctx context.Context, inTar io.ReadCloser, platf
249 249
 	opts := []containerd.ImportOpt{
250 250
 		containerd.WithImportPlatform(pm),
251 251
 
252
+		containerd.WithImportReferrers(newReferrersForImport(i.content)),
253
+
252 254
 		// Create an additional image with dangling name for imported images...
253 255
 		containerd.WithDigestRef(danglingImageName),
254 256
 		// ... but only if they don't have a name or it's invalid.
... ...
@@ -444,3 +452,79 @@ func (i *ImageService) verifyImagesProvidePlatform(ctx context.Context, imgs []c
444 444
 
445 445
 	return errdefs.NotFound(fmt.Errorf(msg, strings.Join(incompleteImgs, ", "), platformNames))
446 446
 }
447
+
448
+type referrersForImport struct {
449
+	store      content.Store
450
+	candidates *referrersList
451
+}
452
+
453
+func newReferrersForImport(store content.Store) *referrersForImport {
454
+	return &referrersForImport{
455
+		store:      store,
456
+		candidates: newReferrersList(),
457
+	}
458
+}
459
+
460
+func (r *referrersForImport) Referrers(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
461
+	if r.candidates.readFrom(ctx, r.store, desc) != nil {
462
+		return nil, nil
463
+	}
464
+	refs, ok := r.candidates.Get(desc.Digest)
465
+	if !ok {
466
+		return nil, nil
467
+	}
468
+	return refs, nil
469
+}
470
+
471
+type referrersForExport struct {
472
+	store content.Store
473
+}
474
+
475
+func newReferrersForExport(store content.Store) *referrersForExport {
476
+	return &referrersForExport{
477
+		store: store,
478
+	}
479
+}
480
+
481
+func (r *referrersForExport) Referrers(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
482
+	info, err := r.store.Info(ctx, desc.Digest)
483
+	if err != nil {
484
+		if errors.Is(err, cerrdefs.ErrNotFound) {
485
+			return nil, nil
486
+		}
487
+		return nil, err
488
+	}
489
+	var refs []ocispec.Descriptor
490
+	for k, v := range info.Labels {
491
+		if strings.HasPrefix(k, "containerd.io/gc.ref.content.referrer.sha256.") {
492
+			dgst, err := digest.Parse(v)
493
+			if err != nil {
494
+				continue
495
+			}
496
+			var desc ocispec.Descriptor
497
+			desc.Digest = dgst
498
+			info, err := r.store.Info(ctx, dgst)
499
+			if err != nil {
500
+				continue
501
+			}
502
+			desc.Size = info.Size
503
+			// parse mediatype and artifact type
504
+			dt, err := content.ReadBlob(ctx, r.store, ocispec.Descriptor{Digest: dgst})
505
+			if err != nil {
506
+				continue
507
+			}
508
+			var mfst ocispec.Manifest
509
+			if err := json.Unmarshal(dt, &mfst); err != nil {
510
+				continue
511
+			}
512
+			desc.MediaType = mfst.MediaType
513
+			if mfst.ArtifactType != "" {
514
+				desc.ArtifactType = mfst.ArtifactType
515
+			}
516
+			// TODO: we should only export signatures but cosign doesn't set artifact type on payload
517
+			refs = append(refs, desc)
518
+		}
519
+	}
520
+
521
+	return refs, nil
522
+}
... ...
@@ -2,19 +2,27 @@ package containerd
2 2
 
3 3
 import (
4 4
 	"context"
5
+	"encoding/json"
5 6
 	"fmt"
7
+	"slices"
6 8
 	"strings"
9
+	"sync"
7 10
 	"sync/atomic"
8 11
 	"time"
9 12
 
10 13
 	containerd "github.com/containerd/containerd/v2/client"
14
+	"github.com/containerd/containerd/v2/core/content"
11 15
 	c8dimages "github.com/containerd/containerd/v2/core/images"
16
+	"github.com/containerd/containerd/v2/core/remotes"
12 17
 	"github.com/containerd/containerd/v2/core/remotes/docker"
13 18
 	"github.com/containerd/containerd/v2/pkg/snapshotters"
14 19
 	cerrdefs "github.com/containerd/errdefs"
15 20
 	"github.com/containerd/log"
16 21
 	"github.com/containerd/platforms"
17 22
 	"github.com/distribution/reference"
23
+	slsa02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2"
24
+	slsa1 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1"
25
+	"github.com/moby/buildkit/util/attestation"
18 26
 	"github.com/moby/moby/api/types/events"
19 27
 	registrytypes "github.com/moby/moby/api/types/registry"
20 28
 	"github.com/moby/moby/v2/daemon/internal/distribution"
... ...
@@ -24,6 +32,8 @@ import (
24 24
 	"github.com/moby/moby/v2/daemon/internal/stringid"
25 25
 	"github.com/moby/moby/v2/daemon/server/imagebackend"
26 26
 	"github.com/moby/moby/v2/errdefs"
27
+	policyimage "github.com/moby/policy-helpers/image"
28
+	"github.com/opencontainers/go-digest"
27 29
 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
28 30
 	"github.com/pkg/errors"
29 31
 )
... ...
@@ -214,7 +224,11 @@ func (i *ImageService) pullTag(ctx context.Context, ref reference.Named, platfor
214 214
 	// this information is used to enable remote snapshotters like nydus and stargz to query a registry.
215 215
 	// This is also needed for the pull progress to detect the `Extracting` status.
216 216
 	infoHandler := snapshotters.AppendInfoHandlerWrapper(ref.String())
217
-	opts = append(opts, containerd.WithImageHandlerWrapper(infoHandler))
217
+
218
+	referrers := newReferrersForPull(ref.String(), resolver, i.client.ContentStore())
219
+
220
+	opts = append(opts, containerd.WithImageHandlerWrapper(joinHandlerWrappers(infoHandler, referrers.Handler)))
221
+	opts = append(opts, containerd.WithReferrersProvider(referrers))
218 222
 
219 223
 	img, err := i.client.Pull(ctx, ref.String(), opts...)
220 224
 	if err != nil {
... ...
@@ -257,9 +271,195 @@ func (i *ImageService) pullTag(ctx context.Context, ref reference.Named, platfor
257 257
 
258 258
 	i.LogImageEvent(ctx, reference.FamiliarString(ref), reference.FamiliarName(ref), events.ActionPull)
259 259
 	outNewImg = img
260
+
260 261
 	return nil
261 262
 }
262 263
 
264
+func joinHandlerWrappers(funcs ...func(c8dimages.Handler) c8dimages.Handler) func(c8dimages.Handler) c8dimages.Handler {
265
+	return func(h c8dimages.Handler) c8dimages.Handler {
266
+		for _, f := range funcs {
267
+			h = f(h)
268
+		}
269
+		return h
270
+	}
271
+}
272
+
273
+type referrersForPull struct {
274
+	mu                    sync.Mutex
275
+	ref                   string
276
+	store                 content.Store
277
+	candidates            *referrersList
278
+	isAttestationManifest map[digest.Digest]struct{}
279
+	resolver              remotes.Resolver
280
+}
281
+
282
+func newReferrersForPull(ref string, resolver remotes.Resolver, st content.Store) *referrersForPull {
283
+	return &referrersForPull{
284
+		ref:                   ref,
285
+		candidates:            newReferrersList(),
286
+		isAttestationManifest: make(map[digest.Digest]struct{}),
287
+		store:                 st,
288
+		resolver:              resolver,
289
+	}
290
+}
291
+
292
+func (h *referrersForPull) Referrers(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
293
+	h.mu.Lock()
294
+	defer h.mu.Unlock()
295
+
296
+	if m, ok := h.candidates.Get(desc.Digest); ok {
297
+		for i, att := range m {
298
+			if att.Annotations[attestation.DockerAnnotationReferenceType] == attestation.DockerAnnotationReferenceTypeDefault {
299
+				att.Platform = nil
300
+				h.isAttestationManifest[att.Digest] = struct{}{}
301
+				m[i] = att
302
+			}
303
+		}
304
+		return m, nil
305
+	} else if _, ok := h.isAttestationManifest[desc.Digest]; ok {
306
+		f, err := h.resolver.Fetcher(ctx, h.ref)
307
+		if err != nil {
308
+			return nil, err
309
+		}
310
+		referrers, ok := f.(remotes.ReferrersFetcher)
311
+		if !ok {
312
+			return nil, errors.New("resolver does not support fetching referrers")
313
+		}
314
+
315
+		// we are currently intentionally not passing filter to FetchReferrers here because
316
+		// of known issue in AWS registry that return empty result when multiple filters are applied
317
+		descs, err := referrers.FetchReferrers(ctx, desc.Digest)
318
+		if err != nil {
319
+			return nil, err
320
+		}
321
+		// manual filtering to work around the issue mentioned above
322
+		filtered := make([]ocispec.Descriptor, 0, len(descs))
323
+		for _, att := range descs {
324
+			switch att.ArtifactType {
325
+			case policyimage.ArtifactTypeCosignSignature, policyimage.ArtifactTypeSigstoreBundle:
326
+				filtered = append(filtered, att)
327
+			}
328
+		}
329
+		return filtered, nil
330
+	}
331
+	return nil, nil
332
+}
333
+
334
+func (h *referrersForPull) Handler(f c8dimages.Handler) c8dimages.Handler {
335
+	return c8dimages.HandlerFunc(func(ctx context.Context, desc ocispec.Descriptor) ([]ocispec.Descriptor, error) {
336
+		children, err := f.Handle(ctx, desc)
337
+		if err != nil {
338
+			return nil, err
339
+		}
340
+
341
+		if err := h.candidates.readFrom(ctx, h.store, desc); err != nil {
342
+			return nil, err
343
+		}
344
+
345
+		h.mu.Lock()
346
+		defer h.mu.Unlock()
347
+		if c8dimages.IsManifestType(desc.MediaType) {
348
+			if _, ok := h.isAttestationManifest[desc.Digest]; ok {
349
+				// for matched attestation manifest, we only need provenance attestation
350
+				dt, err := content.ReadBlob(ctx, h.store, desc)
351
+				if err != nil {
352
+					return nil, err
353
+				}
354
+
355
+				var mfst ocispec.Manifest
356
+				if err := json.Unmarshal(dt, &mfst); err != nil {
357
+					return nil, err
358
+				}
359
+				var provenance []ocispec.Descriptor
360
+				for _, desc := range mfst.Layers {
361
+					pType, ok := desc.Annotations["in-toto.io/predicate-type"]
362
+					if !ok {
363
+						continue
364
+					}
365
+					switch pType {
366
+					case slsa1.PredicateSLSAProvenance, slsa02.PredicateSLSAProvenance:
367
+						provenance = append(provenance, desc)
368
+					default:
369
+					}
370
+				}
371
+				_ = provenance // TODO: filter out non-provenance attestation
372
+			}
373
+		}
374
+		return children, nil
375
+	})
376
+}
377
+
378
+type referrersList struct {
379
+	mu sync.RWMutex
380
+	m  map[digest.Digest][]ocispec.Descriptor
381
+}
382
+
383
+func newReferrersList() *referrersList {
384
+	return &referrersList{
385
+		m: make(map[digest.Digest][]ocispec.Descriptor),
386
+	}
387
+}
388
+func (rl *referrersList) Get(dgst digest.Digest) ([]ocispec.Descriptor, bool) {
389
+	rl.mu.RLock()
390
+	defer rl.mu.RUnlock()
391
+	descs, ok := rl.m[dgst]
392
+	return descs, ok
393
+}
394
+
395
+func (rl *referrersList) readFrom(ctx context.Context, st content.Store, desc ocispec.Descriptor) error {
396
+	if !c8dimages.IsIndexType(desc.MediaType) {
397
+		return nil
398
+	}
399
+
400
+	p, err := content.ReadBlob(ctx, st, desc)
401
+	if err != nil {
402
+		return err
403
+	}
404
+	var index ocispec.Index
405
+	if err := json.Unmarshal(p, &index); err != nil {
406
+		return err
407
+	}
408
+	rl.mu.Lock()
409
+	defer rl.mu.Unlock()
410
+	if rl.m == nil {
411
+		rl.m = make(map[digest.Digest][]ocispec.Descriptor)
412
+	}
413
+	for _, desc := range index.Manifests {
414
+		if !c8dimages.IsManifestType(desc.MediaType) {
415
+			continue
416
+		}
417
+		subject, err := parseSubject(desc)
418
+		if err != nil || subject == "" {
419
+			continue
420
+		}
421
+		rl.m[subject] = slices.DeleteFunc(rl.m[subject], func(d ocispec.Descriptor) bool {
422
+			if d.Digest == desc.Digest {
423
+				return true
424
+			}
425
+			if _, ok := desc.Annotations[attestation.DockerAnnotationReferenceType]; ok {
426
+				// for inline attestation, last ref wins
427
+				return true
428
+			}
429
+			return false
430
+		})
431
+		rl.m[subject] = append(rl.m[subject], desc)
432
+	}
433
+	return nil
434
+}
435
+
436
+func parseSubject(desc ocispec.Descriptor) (digest.Digest, error) {
437
+	var dgstStr string
438
+	if refType, ok := desc.Annotations[attestation.DockerAnnotationReferenceType]; ok && refType == attestation.DockerAnnotationReferenceTypeDefault {
439
+		dgstStr, ok = desc.Annotations[attestation.DockerAnnotationReferenceDigest]
440
+		if !ok {
441
+			return "", errors.New("invalid referrer manifest: missing subject digest")
442
+		}
443
+	} else if subject, ok := desc.Annotations[c8dimages.AnnotationManifestSubject]; ok {
444
+		dgstStr = subject
445
+	}
446
+	return digest.Parse(dgstStr)
447
+}
448
+
263 449
 // writeStatus writes a status message to out. If newerDownloaded is true, the
264 450
 // status message indicates that a newer image was downloaded. Otherwise, it
265 451
 // indicates that the image is up to date. requestedTag is the tag the message
... ...
@@ -86,7 +86,7 @@ func mirrorsToRegistryHosts(mirrors []string, dHost docker.RegistryHost) []docke
86 86
 	var mirrorHosts []docker.RegistryHost
87 87
 	for _, mirror := range mirrors {
88 88
 		h := dHost
89
-		h.Capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve
89
+		h.Capabilities = docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityReferrers
90 90
 
91 91
 		u, err := url.Parse(mirror)
92 92
 		if err != nil || u.Host == "" {
... ...
@@ -9,8 +9,8 @@ import (
9 9
 )
10 10
 
11 11
 func TestMirrorsToHosts(t *testing.T) {
12
-	pullCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve
13
-	allCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush
12
+	pullCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityReferrers
13
+	allCaps := docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityPush | docker.HostCapabilityReferrers
14 14
 	defaultRegistry := testRegistryHost("https", "registry-1.docker.com", "/v2", allCaps)
15 15
 	for _, tc := range []struct {
16 16
 		mirrors  []string
... ...
@@ -73,7 +73,7 @@ func (pm *Manager) registryHostsFn(auth *registry.AuthConfig, httpFallback bool)
73 73
 				continue
74 74
 			}
75 75
 
76
-			caps := docker.HostCapabilityPull | docker.HostCapabilityResolve
76
+			caps := docker.HostCapabilityPull | docker.HostCapabilityResolve | docker.HostCapabilityReferrers
77 77
 			if !ep.Mirror {
78 78
 				caps = caps | docker.HostCapabilityPush
79 79
 			}
... ...
@@ -51,6 +51,7 @@ require (
51 51
 	github.com/hashicorp/go-memdb v1.3.5
52 52
 	github.com/hashicorp/memberlist v0.4.0
53 53
 	github.com/hashicorp/serf v0.8.5
54
+	github.com/in-toto/in-toto-golang v0.9.0
54 55
 	github.com/ishidawataru/sctp v0.0.0-20250829011129-4b890084db30
55 56
 	github.com/miekg/dns v1.1.66
56 57
 	github.com/mistifyio/go-zfs/v3 v3.0.1
... ...
@@ -63,6 +64,7 @@ require (
63 63
 	github.com/moby/moby/api v1.52.0
64 64
 	github.com/moby/moby/client v0.1.0
65 65
 	github.com/moby/patternmatcher v0.6.0
66
+	github.com/moby/policy-helpers v0.0.0-20251105011237-bcaa71c99f14
66 67
 	github.com/moby/profiles/apparmor v0.1.0
67 68
 	github.com/moby/profiles/seccomp v0.1.0
68 69
 	github.com/moby/pubsub v1.0.0
... ...
@@ -188,13 +190,11 @@ require (
188 188
 	github.com/hashicorp/golang-lru v0.5.4 // indirect
189 189
 	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
190 190
 	github.com/hiddeco/sshsig v0.2.0 // indirect
191
-	github.com/in-toto/in-toto-golang v0.9.0 // indirect
192 191
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
193 192
 	github.com/jmoiron/sqlx v1.3.3 // indirect
194 193
 	github.com/klauspost/compress v1.18.1 // indirect
195 194
 	github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
196 195
 	github.com/mitchellh/reflectwalk v1.0.2 // indirect
197
-	github.com/moby/policy-helpers v0.0.0-20251105011237-bcaa71c99f14 // indirect
198 196
 	github.com/moby/sys/capability v0.4.0 // indirect
199 197
 	github.com/morikuni/aec v1.0.0 // indirect
200 198
 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect