diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..e214ae0 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,2 @@ +ignore: + - "internal/test" diff --git a/pkg/attest/example_sign_test.go b/pkg/attest/example_sign_test.go index 0a90d36..db39cc9 100644 --- a/pkg/attest/example_sign_test.go +++ b/pkg/attest/example_sign_test.go @@ -32,22 +32,30 @@ func ExampleSign_remote() { // load image index with unsigned attestation-manifests ref := "docker/image-signer-verifier:latest" - att, err := oci.SubjectIndexFromRemote(ref) + attIdx, err := oci.IndexFromRemote(ref) if err != nil { panic(err) } // example for local image index // path := "/myimage" - // att, err := oci.AttestationIndexFromLocal(path) + // attIdx, err = oci.IndexFromPath(path) + // if err != nil { + // panic(err) + // } - // sign attestations - signedImageIndex, err := attest.Sign(context.Background(), att.Index, signer, opts) + // sign all attestations in an image index + signedManifests, err := attest.SignStatements(context.Background(), attIdx.Index, signer, opts) + if err != nil { + panic(err) + } + signedIndex := attIdx.Index + signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) if err != nil { panic(err) } // push image index with signed attestation-manifests - err = mirror.PushIndexToRegistry(signedImageIndex, ref) + err = mirror.PushIndexToRegistry(signedIndex, ref) if err != nil { panic(err) } @@ -55,10 +63,10 @@ func ExampleSign_remote() { path := "/myimage" idx := v1.ImageIndex(empty.Index) idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ - Add: signedImageIndex, + Add: signedIndex, Descriptor: v1.Descriptor{ Annotations: map[string]string{ - oci.OciReferenceTarget: att.Name, + oci.OciReferenceTarget: attIdx.Name, }, }, }) diff --git a/pkg/attest/sign.go b/pkg/attest/sign.go index 6865f42..1d10506 100644 --- a/pkg/attest/sign.go +++ b/pkg/attest/sign.go @@ -2,246 +2,25 @@ package attest import ( "context" - "encoding/json" "fmt" "github.com/docker/attest/pkg/attestation" - "github.com/docker/attest/pkg/oci" v1 "github.com/google/go-containerregistry/pkg/v1" - "github.com/google/go-containerregistry/pkg/v1/empty" - "github.com/google/go-containerregistry/pkg/v1/match" - "github.com/google/go-containerregistry/pkg/v1/mutate" - "github.com/google/go-containerregistry/pkg/v1/partial" - "github.com/google/go-containerregistry/pkg/v1/static" - "github.com/google/go-containerregistry/pkg/v1/types" - intoto "github.com/in-toto/in-toto-golang/in_toto" "github.com/secure-systems-lab/go-securesystemslib/dsse" ) -func Sign(ctx context.Context, idx v1.ImageIndex, signer dsse.SignerVerifier, opts *attestation.SigningOptions) (v1.ImageIndex, error) { - images, err := SignedAttestationImages(ctx, idx, signer, opts) - if err != nil { - return nil, fmt.Errorf("failed to sign attestation images: %w", err) - } - for _, image := range images { - idx, err = addImageToIndex(idx, image.Image, image.Descriptor, image.AttestationManifest) - if err != nil { - return nil, fmt.Errorf("failed to add signed layers to index: %w", err) - } - } - return idx, nil -} - -func SignedAttestationImages(ctx context.Context, idx v1.ImageIndex, signer dsse.SignerVerifier, opts *attestation.SigningOptions) ([]*attestation.SignedAttestationImage, error) { +// this is only relevant if there are (unsigned) in-toto statements +func SignStatements(ctx context.Context, idx v1.ImageIndex, signer dsse.SignerVerifier, opts *attestation.SigningOptions) ([]*attestation.AttestationManifest, error) { // extract attestation manifests from index attestationManifests, err := attestation.GetAttestationManifestsFromIndex(idx) if err != nil { - return nil, fmt.Errorf("failed to get attestation manifests: %w", err) + return nil, fmt.Errorf("failed to load attestation manifests from index: %w", err) } - if len(attestationManifests) == 0 { - return nil, fmt.Errorf("no attestation manifests found") - } - images := []*attestation.SignedAttestationImage{} // sign every attestation layer in each manifest for _, manifest := range attestationManifests { - newImg, newDescriptor, err := SignLayersAndAddToImage(ctx, manifest.Attestation.Layers, manifest, signer, opts) - if err != nil { - return nil, fmt.Errorf("failed to add signed layers to image: %w", err) - } - images = append(images, &attestation.SignedAttestationImage{ - Image: newImg, - Descriptor: newDescriptor, - AttestationManifest: manifest, - }) - } - return images, nil -} - -func AddAttestation(ctx context.Context, idx v1.ImageIndex, statement *intoto.Statement, signer dsse.SignerVerifier) (v1.ImageIndex, error) { - if len(statement.Subject) == 0 { - return nil, fmt.Errorf("statement has no subjects") - } - - subjectDigests := make(map[string]bool) - for _, subject := range statement.Subject { - subjectDigest := fmt.Sprintf("sha256:%s", subject.Digest["sha256"]) - subjectDigests[subjectDigest] = true - } - - attestationManifests, err := attestation.GetAttestationManifestsFromIndex(idx) - if err != nil { - return nil, fmt.Errorf("failed to get attestation manifests: %w", err) - } - updatedIndex := false - for _, manifest := range attestationManifests { - if subjectDigests[manifest.Annotations[attestation.DockerReferenceDigest]] { - attestationLayers := []attestation.AttestationLayer{ - { - Statement: statement, - MediaType: types.MediaType(intoto.PayloadType), - Annotations: map[string]string{ - oci.InTotoPredicateType: statement.PredicateType, - }, - }, - } - // hard-coding replace to false here, because if it's true we will remove any unsigned statements, even unrelated ones - newImg, newDec, err := SignLayersAndAddToImage(ctx, attestationLayers, manifest, signer, &attestation.SigningOptions{Replace: false}) - if err != nil { - return nil, fmt.Errorf("failed to add signed layers to image: %w", err) - } - idx, err = addImageToIndex(idx, newImg, newDec, manifest) - if err != nil { - return nil, fmt.Errorf("failed to add attestation image to index: %w", err) - } - updatedIndex = true - } - } - if !updatedIndex { - return nil, fmt.Errorf("no attestation manifest found for statement") - } - return idx, nil -} - -func SignLayersAndAddToImage( - ctx context.Context, - attestationLayers []attestation.AttestationLayer, - manifest attestation.AttestationManifest, - signer dsse.SignerVerifier, - opts *attestation.SigningOptions) (v1.Image, *v1.Descriptor, error) { - - signedLayers, err := signLayers(ctx, attestationLayers, signer, opts) - if err != nil { - return nil, nil, fmt.Errorf("failed to sign attestations: %w", err) - } - - newImg, err := addSignedLayers(signedLayers, manifest, opts) - if err != nil { - return nil, nil, fmt.Errorf("failed to add signed layers: %w", err) - } - if !opts.SkipSubject { - newImg = mutate.Subject(newImg, *manifest.SubjectDescriptor).(v1.Image) - } - newDesc, err := partial.Descriptor(newImg) - if err != nil { - return nil, nil, fmt.Errorf("failed to get descriptor: %w", err) - } - cf, err := manifest.Attestation.Image.ConfigFile() - if err != nil { - return nil, nil, fmt.Errorf("failed to get config file: %w", err) - } - newDesc.Platform = cf.Platform() - if newDesc.Platform == nil { - newDesc.Platform = &v1.Platform{ - Architecture: "unknown", - OS: "unknown", - } - } - newDesc.MediaType = manifest.MediaType - newDesc.Annotations = manifest.Annotations - - return newImg, newDesc, nil -} - -func addImageToIndex( - idx v1.ImageIndex, - img v1.Image, - desc *v1.Descriptor, - manifest attestation.AttestationManifest, -) (v1.ImageIndex, error) { - - idx = mutate.RemoveManifests(idx, match.Digests(manifest.Digest)) - idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ - Add: img, - Descriptor: *desc, - }) - return idx, nil -} - -// signLayers signs each intoto attestation layer with the given signer -func signLayers(ctx context.Context, layers []attestation.AttestationLayer, signer dsse.SignerVerifier, opts *attestation.SigningOptions) ([]mutate.Addendum, error) { - var signedLayers []mutate.Addendum - for _, layer := range layers { - // only sign intoto layers - if layer.MediaType != types.MediaType(intoto.PayloadType) { - continue - } - // mark attestation as experimental - layer.Annotations[InTotoReferenceLifecycleStage] = LifecycleStageExperimental - - // sign the statement - env, err := signInTotoStatement(ctx, layer.Statement, signer, opts) - if err != nil { - return nil, fmt.Errorf("failed to sign statement: %w", err) - } - - mediaType, err := attestation.DSSEMediaType(layer.Statement.PredicateType) - if err != nil { - return nil, fmt.Errorf("failed to get DSSE media type: %w", err) - } - data, err := json.Marshal(env) - if err != nil { - return nil, fmt.Errorf("failed to marshal envelope: %w", err) - } - newLayer := static.NewLayer(data, types.MediaType(mediaType)) - withAnnotations := mutate.Addendum{ - Layer: newLayer, - Annotations: layer.Annotations, - } - signedLayers = append(signedLayers, withAnnotations) - } - return signedLayers, nil -} - -func signInTotoStatement(ctx context.Context, statement *intoto.Statement, signer dsse.SignerVerifier, opts *attestation.SigningOptions) (*attestation.Envelope, error) { - payload, err := json.Marshal(statement) - if err != nil { - return nil, fmt.Errorf("failed to marshal statement: %w", err) - } - env, err := attestation.SignDSSE(ctx, payload, signer, opts) - if err != nil { - return nil, fmt.Errorf("failed to sign statement: %w", err) - } - return env, nil -} - -// addSignedLayers adds signed layers to a new or existing attestation image -func addSignedLayers(signedLayers []mutate.Addendum, manifest attestation.AttestationManifest, opts *attestation.SigningOptions) (v1.Image, error) { - withAnnotations := func(img v1.Image) v1.Image { - // this is handy when dealing with referrers - return mutate.Annotations(img, map[string]string{ - attestation.DockerReferenceType: attestation.AttestationManifestType, - attestation.DockerReferenceDigest: manifest.SubjectDescriptor.Digest.String(), - }).(v1.Image) - } - var err error - if opts.Replace { - // create a new attestation image with only signed layers - newImg := empty.Image - newImg = mutate.MediaType(newImg, manifest.MediaType) - newImg = mutate.ConfigMediaType(newImg, "application/vnd.oci.image.config.v1+json") - for _, layer := range signedLayers { - newImg, err = mutate.Append(newImg, layer) - if err != nil { - return nil, fmt.Errorf("failed to append signed layer: %w", err) - } - } - // add any existing unsigned (non-intoto) layers to the new image for _, layer := range manifest.Attestation.Layers { - if layer.MediaType != types.MediaType(intoto.PayloadType) { - newImg, err = mutate.AppendLayers(newImg, layer.Layer) - if err != nil { - return nil, fmt.Errorf("failed to append unsigned layer: %w", err) - } - } - } - return withAnnotations(newImg), nil - } - // Add signed layers to the existing image - for _, layer := range signedLayers { - manifest.Attestation.Image, err = mutate.Append(manifest.Attestation.Image, layer) - if err != nil { - return nil, fmt.Errorf("failed to append layer: %w", err) + manifest.AddAttestation(ctx, signer, layer.Statement, opts) } } - return withAnnotations(manifest.Attestation.Image), nil + return attestationManifests, nil } diff --git a/pkg/attest/sign_test.go b/pkg/attest/sign_test.go index 8769674..3ff8865 100644 --- a/pkg/attest/sign_test.go +++ b/pkg/attest/sign_test.go @@ -2,7 +2,6 @@ package attest import ( "encoding/json" - "fmt" "path/filepath" "testing" @@ -41,7 +40,6 @@ func TestSignVerifyOCILayout(t *testing.T) { expectedAttestations int replace bool }{ - {"signed replaced", UnsignedTestImage, 0, 4, true}, {"without replace", UnsignedTestImage, 4, 4, false}, // image without provenance doesn't fail @@ -57,11 +55,13 @@ func TestSignVerifyOCILayout(t *testing.T) { opts := &attestation.SigningOptions{ Replace: tc.replace, } - attIdx, err := oci.SubjectIndexFromPath(tc.TestImage) + attIdx, err := oci.IndexFromPath(tc.TestImage) require.NoError(t, err) - signedIndex, err := Sign(ctx, attIdx.Index, signer, opts) + signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + signedIndex := attIdx.Index + signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) require.NoError(t, err) - // output signed attestations idx := v1.ImageIndex(empty.Index) idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ @@ -88,8 +88,8 @@ func TestSignVerifyOCILayout(t *testing.T) { allEnvelopes = append(allEnvelopes, statements...) for _, stmt := range statements { - assert.Equalf(t, predicate, stmt.Annotations[oci.InTotoPredicateType], "expected predicate-type annotation to be set to %s, got %s", predicate, stmt.Annotations[oci.InTotoPredicateType]) - assert.Equalf(t, LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage], "expected reference lifecycle stage annotation to be set to %s, got %s", LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage]) + assert.Equalf(t, predicate, stmt.Annotations[attestation.InTotoPredicateType], "expected predicate-type annotation to be set to %s, got %s", predicate, stmt.Annotations[attestation.InTotoPredicateType]) + assert.Equalf(t, attestation.LifecycleStageExperimental, stmt.Annotations[attestation.InTotoReferenceLifecycleStage], "expected reference lifecycle stage annotation to be set to %s, got %s", attestation.LifecycleStageExperimental, stmt.Annotations[attestation.InTotoReferenceLifecycleStage]) } } assert.Equalf(t, tc.expectedAttestations, len(allEnvelopes), "expected %d attestations, got %d", tc.expectedAttestations, len(allEnvelopes)) @@ -100,70 +100,6 @@ func TestSignVerifyOCILayout(t *testing.T) { } } -func TestAddAttestation(t *testing.T) { - ctx, signer := test.Setup(t) - - expectedAttestations := 2 - expectedStatements := 4 - - outputLayout := test.CreateTempDir(t, "", TestTempDir) - attIdx, err := oci.SubjectIndexFromPath(UnsignedTestImage) - require.NoError(t, err) - - statementToAdd := &intoto.Statement{ - StatementHeader: intoto.StatementHeader{ - PredicateType: attestation.VSAPredicateType, - Type: intoto.StatementInTotoV01, - Subject: []intoto.Subject{ - { - Name: attIdx.Name, - Digest: map[string]string{ - "sha256": "da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620", - }, - }, - { - Name: attIdx.Name, - Digest: map[string]string{ - "sha256": "7a76cec943853f9f7105b1976afa1bf7cd5bb6afc4e9d5852dd8da7cf81ae86e", - }, - }, - }, - }, - } - - signedIndex, err := AddAttestation(ctx, attIdx.Index, statementToAdd, signer) - require.NoError(t, err) - - // output signed attestations - idx := v1.ImageIndex(empty.Index) - idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ - Add: signedIndex, - Descriptor: v1.Descriptor{ - Annotations: map[string]string{ - oci.OciReferenceTarget: attIdx.Name, - }, - }, - }) - _, err = layout.Write(outputLayout, idx) - require.NoError(t, err) - - var allEnvelopes []*test.AnnotatedStatement - mt, _ := attestation.DSSEMediaType(attestation.VSAPredicateType) - statements, err := test.ExtractAnnotatedStatements(outputLayout, mt) - require.NoError(t, err) - allEnvelopes = append(allEnvelopes, statements...) - - for _, stmt := range statements { - assert.Equalf(t, attestation.VSAPredicateType, stmt.Annotations[oci.InTotoPredicateType], "expected predicate-type annotation to be set to %s, got %s", attestation.VSAPredicateType, stmt.Annotations[oci.InTotoPredicateType]) - assert.Equalf(t, LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage], "expected reference lifecycle stage annotation to be set to %s, got %s", LifecycleStageExperimental, stmt.Annotations[InTotoReferenceLifecycleStage]) - } - assert.Equalf(t, expectedAttestations, len(allEnvelopes), "expected %d attestations, got %d", expectedAttestations, len(allEnvelopes)) - statements, err = test.ExtractAnnotatedStatements(outputLayout, intoto.PayloadType) - fmt.Printf("statements: %+v\n", statements) - require.NoError(t, err) - assert.Equalf(t, expectedStatements, len(statements), "expected %d statement, got %d", expectedStatements, len(statements)) -} - func TestAddSignedLayerAnnotations(t *testing.T) { testCases := []struct { name string @@ -176,33 +112,29 @@ func TestAddSignedLayerAnnotations(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { data := []byte("signed") - signedLayer := static.NewLayer(data, types.MediaType(intoto.PayloadType)) - signedLayers := []mutate.Addendum{ - { - Layer: signedLayer, - Annotations: map[string]string{"test": "test"}, - }, - } - data = []byte("test") testLayer := static.NewLayer(data, types.MediaType(intoto.PayloadType)) mediaType := types.OCIManifestSchema1 opts := &attestation.SigningOptions{ Replace: tc.replace, } - manifest := attestation.AttestationManifest{ + originalLayer := &attestation.AttestationLayer{ + Layer: testLayer, + Statement: &intoto.Statement{}, + Annotations: map[string]string{"test": "test"}, + } + + manifest := &attestation.AttestationManifest{ MediaType: mediaType, - Attestation: attestation.AttestationImage{ + Attestation: &attestation.AttestationImage{ Image: empty.Image, - Layers: []attestation.AttestationLayer{ - { - Layer: testLayer, - Statement: &intoto.Statement{}, - }, + Layers: []*attestation.AttestationLayer{ + originalLayer, }, }, SubjectDescriptor: &v1.Descriptor{}, } - newImg, err := addSignedLayers(signedLayers, manifest, opts) + err := manifest.AddOrReplaceLayer(originalLayer, opts) + newImg := manifest.Attestation.Image require.NoError(t, err) mf, _ := newImg.RawManifest() type Annotations struct { diff --git a/pkg/attest/types.go b/pkg/attest/types.go index 5a37bab..4b77f2d 100644 --- a/pkg/attest/types.go +++ b/pkg/attest/types.go @@ -7,11 +7,6 @@ import ( intoto "github.com/in-toto/in-toto-golang/in_toto" ) -const ( - InTotoReferenceLifecycleStage = "vnd.docker.lifecycle-stage" - LifecycleStageExperimental = "experimental" -) - type Outcome string const ( diff --git a/pkg/attest/verify_test.go b/pkg/attest/verify_test.go index 287c524..d746774 100644 --- a/pkg/attest/verify_test.go +++ b/pkg/attest/verify_test.go @@ -80,10 +80,13 @@ func TestVSA(t *testing.T) { opts := &attestation.SigningOptions{ Replace: true, } - attIdx, err := oci.SubjectIndexFromPath(UnsignedTestImage) - assert.NoError(t, err) - signedIndex, err := Sign(ctx, attIdx.Index, signer, opts) + attIdx, err := oci.IndexFromPath(UnsignedTestImage) assert.NoError(t, err) + signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + signedIndex := attIdx.Index + signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) + require.NoError(t, err) // output signed attestations idx := v1.ImageIndex(empty.Index) @@ -136,10 +139,13 @@ func TestVerificationFailure(t *testing.T) { opts := &attestation.SigningOptions{ Replace: true, } - attIdx, err := oci.SubjectIndexFromPath(UnsignedTestImage) - assert.NoError(t, err) - signedIndex, err := Sign(ctx, attIdx.Index, signer, opts) + attIdx, err := oci.IndexFromPath(UnsignedTestImage) assert.NoError(t, err) + signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + signedIndex := attIdx.Index + signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) + require.NoError(t, err) // output signed attestations idx := v1.ImageIndex(empty.Index) @@ -201,7 +207,7 @@ func TestSignVerifyNoTL(t *testing.T) { {name: "no tl", signTL: false, policyDir: PassPolicyDir, success: false}, } - attIdx, err := oci.SubjectIndexFromPath(UnsignedTestImage) + attIdx, err := oci.IndexFromPath(UnsignedTestImage) assert.NoError(t, err) for _, tc := range testCases { @@ -211,9 +217,11 @@ func TestSignVerifyNoTL(t *testing.T) { SkipTL: tc.signTL, } - signedIndex, err := Sign(ctx, attIdx.Index, signer, opts) - assert.NoError(t, err) - + signedManifests, err := SignStatements(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + signedIndex := attIdx.Index + signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) + require.NoError(t, err) // output signed attestations idx := v1.ImageIndex(empty.Index) idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ diff --git a/pkg/attestation/attestation.go b/pkg/attestation/attestation.go index fb53106..1b449f2 100644 --- a/pkg/attestation/attestation.go +++ b/pkg/attestation/attestation.go @@ -1,18 +1,24 @@ package attestation import ( + "context" "encoding/json" "fmt" "maps" v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/match" + "github.com/google/go-containerregistry/pkg/v1/mutate" "github.com/google/go-containerregistry/pkg/v1/partial" + "github.com/google/go-containerregistry/pkg/v1/static" "github.com/google/go-containerregistry/pkg/v1/types" intoto "github.com/in-toto/in-toto-golang/in_toto" + "github.com/secure-systems-lab/go-securesystemslib/dsse" ) // GetAttestationManifestsFromIndex extracts all attestation manifests from an index -func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]AttestationManifest, error) { +func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]*AttestationManifest, error) { idx, err := index.IndexManifest() if err != nil { return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err) @@ -22,9 +28,8 @@ func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]AttestationManifes subjects[subject.Digest.String()] = &subject } - var attestationManifests []AttestationManifest + var attestationManifests []*AttestationManifest for _, manifest := range idx.Manifests { - if manifest.Annotations[DockerReferenceType] == AttestationManifestType { subject := subjects[manifest.Annotations[DockerReferenceDigest]] if subject == nil { @@ -39,10 +44,10 @@ func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]AttestationManifes return nil, fmt.Errorf("failed to get attestations from image: %w", err) } attestationManifests = append(attestationManifests, - AttestationManifest{ - Descriptor: manifest, + &AttestationManifest{ + Descriptor: &manifest, SubjectDescriptor: subject, - Attestation: AttestationImage{ + Attestation: &AttestationImage{ Layers: attestationLayers, Image: attestationImage}, MediaType: manifest.MediaType, @@ -54,12 +59,12 @@ func GetAttestationManifestsFromIndex(index v1.ImageIndex) ([]AttestationManifes } // GetAttestationsFromImage extracts all attestation layers from an image -func GetAttestationsFromImage(image v1.Image) ([]AttestationLayer, error) { +func GetAttestationsFromImage(image v1.Image) ([]*AttestationLayer, error) { layers, err := image.Layers() if err != nil { return nil, fmt.Errorf("failed to extract layers from image: %w", err) } - var attestationLayers []AttestationLayer + var attestationLayers []*AttestationLayer for _, layer := range layers { // parse layer blob as json r, err := layer.Uncompressed() @@ -85,7 +90,162 @@ func GetAttestationsFromImage(image v1.Image) ([]AttestationLayer, error) { return nil, fmt.Errorf("failed to decode statement layer contents: %w", err) } } - attestationLayers = append(attestationLayers, AttestationLayer{Layer: layer, MediaType: mt, Statement: stmt, Annotations: ann}) + attestationLayers = append(attestationLayers, &AttestationLayer{Layer: layer, MediaType: mt, Statement: stmt, Annotations: ann}) } return attestationLayers, nil } + +func (manifest *AttestationManifest) AddAttestation(ctx context.Context, signer dsse.SignerVerifier, statement *intoto.Statement, opts *SigningOptions) error { + layer, err := createSignedImageLayer(ctx, statement, signer, opts) + if err != nil { + return fmt.Errorf("failed to create signed layer: %w", err) + } + newImg, newDesc, err := addLayerToImage(manifest, layer, opts) + if err != nil { + return fmt.Errorf("failed to add signed layers to image: %w", err) + } + manifest.Attestation.Image = newImg + manifest.Descriptor = newDesc + return nil +} + +func createSignedImageLayer(ctx context.Context, statement *intoto.Statement, signer dsse.SignerVerifier, opts *SigningOptions) (*AttestationLayer, error) { + + // sign the statement + env, err := SignInTotoStatement(ctx, statement, signer, opts) + if err != nil { + return nil, fmt.Errorf("failed to sign statement: %w", err) + } + + mediaType, err := DSSEMediaType(statement.PredicateType) + if err != nil { + return nil, fmt.Errorf("failed to get DSSE media type: %w", err) + } + data, err := json.Marshal(env) + if err != nil { + return nil, fmt.Errorf("failed to marshal envelope: %w", err) + } + return &AttestationLayer{ + Statement: statement, + MediaType: types.MediaType(intoto.PayloadType), + Annotations: map[string]string{ + InTotoPredicateType: statement.PredicateType, + InTotoReferenceLifecycleStage: LifecycleStageExperimental, + }, + Layer: static.NewLayer(data, types.MediaType(mediaType)), + }, nil +} + +func SignInTotoStatement(ctx context.Context, statement *intoto.Statement, signer dsse.SignerVerifier, opts *SigningOptions) (*Envelope, error) { + payload, err := json.Marshal(statement) + if err != nil { + return nil, fmt.Errorf("failed to marshal statement: %w", err) + } + env, err := SignDSSE(ctx, payload, signer, opts) + if err != nil { + return nil, fmt.Errorf("failed to sign statement: %w", err) + } + return env, nil +} + +func addLayerToImage( + manifest *AttestationManifest, + layer *AttestationLayer, + opts *SigningOptions) (v1.Image, *v1.Descriptor, error) { + + err := manifest.AddOrReplaceLayer(layer, opts) + + if err != nil { + return nil, nil, fmt.Errorf("failed to add signed layers: %w", err) + } + newImg := manifest.Attestation.Image + if !opts.SkipSubject { + newImg = mutate.Subject(newImg, *manifest.SubjectDescriptor).(v1.Image) + } + newDesc, err := partial.Descriptor(newImg) + if err != nil { + return nil, nil, fmt.Errorf("failed to get descriptor: %w", err) + } + cf, err := manifest.Attestation.Image.ConfigFile() + if err != nil { + return nil, nil, fmt.Errorf("failed to get config file: %w", err) + } + newDesc.Platform = cf.Platform() + if newDesc.Platform == nil { + newDesc.Platform = &v1.Platform{ + Architecture: "unknown", + OS: "unknown", + } + } + newDesc.MediaType = manifest.MediaType + newDesc.Annotations = manifest.Annotations + return newImg, newDesc, nil +} + +// AddOrReplaceLayer adds signed layers to a new or existing attestation image +// NOTE: the pointers attestation.AttestationLayer.Statement are compared when replacing, +// so make sure you are signing a layer extracted from the original attestation-manifest image! +func (manifest *AttestationManifest) AddOrReplaceLayer(signedLayer *AttestationLayer, opts *SigningOptions) error { + var err error + // always create a new image from all the layers + newImg := empty.Image + newImg = mutate.Annotations(newImg, map[string]string{ + DockerReferenceType: AttestationManifestType, + DockerReferenceDigest: manifest.SubjectDescriptor.Digest.String(), + }).(v1.Image) + + newImg = mutate.MediaType(newImg, manifest.MediaType) + newImg = mutate.ConfigMediaType(newImg, "application/vnd.oci.image.config.v1+json") + add := mutate.Addendum{ + Layer: signedLayer.Layer, + Annotations: signedLayer.Annotations, + } + newImg, err = mutate.Append(newImg, add) + if err != nil { + return fmt.Errorf("failed to add signed layer to image: %w", err) + } + layers := make([]*AttestationLayer, 0) + for _, layer := range manifest.Attestation.Layers { + if layer.Statement == signedLayer.Statement && opts.Replace { + continue + } + add := mutate.Addendum{ + Layer: layer.Layer, + Annotations: layer.Annotations, + } + newImg, err = mutate.Append(newImg, add) + layers = append(layers, layer) + if err != nil { + return fmt.Errorf("failed to add layer to image: %w", err) + } + } + manifest.Attestation.Layers = append(layers, signedLayer) + manifest.Attestation.Image = newImg + return nil +} + +func AddImageToIndex( + idx v1.ImageIndex, + manifest *AttestationManifest, +) (v1.ImageIndex, error) { + idx = mutate.RemoveManifests(idx, match.Digests(manifest.Digest)) + idx = mutate.AppendManifests(idx, mutate.IndexAddendum{ + Add: manifest.Attestation.Image, + Descriptor: *manifest.Descriptor, + }) + return idx, nil +} + +func AddImagesToIndex( + idx v1.ImageIndex, + manifests []*AttestationManifest, +) (v1.ImageIndex, error) { + for _, manifest := range manifests { + var err error + idx, err = AddImageToIndex(idx, manifest) + if err != nil { + return nil, fmt.Errorf("failed to add image to index: %w", err) + } + } + return idx, nil +} diff --git a/pkg/attestation/referrers_test.go b/pkg/attestation/referrers_test.go index 8ec705f..3735b59 100644 --- a/pkg/attestation/referrers_test.go +++ b/pkg/attestation/referrers_test.go @@ -97,7 +97,7 @@ func TestAttestationReferenceTypes(t *testing.T) { Replace: true, SkipSubject: tc.skipSubject, } - attIdx, err := oci.SubjectIndexFromPath(UnsignedTestImage) + attIdx, err := oci.IndexFromPath(UnsignedTestImage) require.NoError(t, err) indexName := fmt.Sprintf("%s/repo:root", u.Host) @@ -108,15 +108,18 @@ func TestAttestationReferenceTypes(t *testing.T) { require.NoError(t, err) repo := fmt.Sprintf("%s/referrers", ru.Host) tc.referrersRepo = repo - images, err := attest.SignedAttestationImages(ctx, attIdx.Index, signer, opts) + signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts) require.NoError(t, err) err = mirror.PushIndexToRegistry(attIdx.Index, indexName) - for _, img := range images { - err = mirror.PushImageToRegistry(img.Image, fmt.Sprintf("%s:tag-does-not-matter", repo)) + for _, img := range signedManifests { + err = mirror.PushImageToRegistry(img.Attestation.Image, fmt.Sprintf("%s:tag-does-not-matter", repo)) require.NoError(t, err) } } else { - signedIndex, err := attest.Sign(ctx, attIdx.Index, signer, opts) + signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + signedIndex := attIdx.Index + signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) require.NoError(t, err) err = mirror.PushIndexToRegistry(signedIndex, indexName) require.NoError(t, err) @@ -215,20 +218,20 @@ func TestReferencesInDifferentRepo(t *testing.T) { Replace: true, SkipTL: true, } - attIdx, err := oci.SubjectIndexFromPath(UnsignedTestImage) + attIdx, err := oci.IndexFromPath(UnsignedTestImage) require.NoError(t, err) indexName := fmt.Sprintf("%s/%s:latest", serverUrl.Host, repoName) err = mirror.PushIndexToRegistry(attIdx.Index, indexName) require.NoError(t, err) - signedImages, err := attest.SignedAttestationImages(ctx, attIdx.Index, signer, opts) + signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts) require.NoError(t, err) // push signed attestation image to the ref server - for _, img := range signedImages { + for _, img := range signedManifests { // push references using subject-digest.att convention - err = mirror.PushImageToRegistry(img.Image, fmt.Sprintf("%s/%s:tag-does-not-matter", refServerUrl.Host, repoName)) + err = mirror.PushImageToRegistry(img.Attestation.Image, fmt.Sprintf("%s/%s:tag-does-not-matter", refServerUrl.Host, repoName)) require.NoError(t, err) } mfs2, err := attIdx.Index.IndexManifest() diff --git a/pkg/attestation/types.go b/pkg/attestation/types.go index ac40453..595f6c5 100644 --- a/pkg/attestation/types.go +++ b/pkg/attestation/types.go @@ -12,12 +12,15 @@ import ( ) const ( - DockerReferenceType = "vnd.docker.reference.type" - AttestationManifestType = "attestation-manifest" - DockerReferenceDigest = "vnd.docker.reference.digest" - DockerDsseExtKind = "application/vnd.docker.attestation-verification.v1+json" - RekorTlExtKind = "Rekor" - OCIDescriptorDSSEMediaType = ociv1.MediaTypeDescriptor + "+dsse" + DockerReferenceType = "vnd.docker.reference.type" + AttestationManifestType = "attestation-manifest" + InTotoPredicateType = "in-toto.io/predicate-type" + DockerReferenceDigest = "vnd.docker.reference.digest" + DockerDsseExtKind = "application/vnd.docker.attestation-verification.v1+json" + RekorTlExtKind = "Rekor" + OCIDescriptorDSSEMediaType = ociv1.MediaTypeDescriptor + "+dsse" + InTotoReferenceLifecycleStage = "vnd.docker.lifecycle-stage" + LifecycleStageExperimental = "experimental" ) var base64Encoding = base64.StdEncoding.Strict() @@ -30,19 +33,19 @@ type AttestationLayer struct { } type AttestationImage struct { - Layers []AttestationLayer + Layers []*AttestationLayer Image v1.Image } type SignedAttestationImage struct { Image v1.Image Descriptor *v1.Descriptor - AttestationManifest AttestationManifest + AttestationManifest *AttestationManifest } type AttestationManifest struct { - Descriptor v1.Descriptor - Attestation AttestationImage + Descriptor *v1.Descriptor + Attestation *AttestationImage MediaType types.MediaType Annotations map[string]string Digest v1.Hash diff --git a/pkg/mirror/authn_test.go b/pkg/mirror/authn_test.go index ce4374d..fd5476b 100644 --- a/pkg/mirror/authn_test.go +++ b/pkg/mirror/authn_test.go @@ -14,7 +14,7 @@ import ( func TestRegistryAuth(t *testing.T) { UnsignedTestImage := filepath.Join("..", "..", "test", "testdata", "unsigned-test-image") - attIdx, err := oci.SubjectIndexFromPath(UnsignedTestImage) + attIdx, err := oci.IndexFromPath(UnsignedTestImage) require.NoError(t, err) // test cases for ecr, gcr and dockerhub testCases := []struct { @@ -27,7 +27,7 @@ func TestRegistryAuth(t *testing.T) { t.Run(tc.Image, func(t *testing.T) { err := mirror.PushIndexToRegistry(attIdx.Index, tc.Image) require.NoError(t, err) - _, err = oci.SubjectIndexFromRemote(tc.Image) + _, err = oci.IndexFromRemote(tc.Image) require.NoError(t, err) }) } diff --git a/pkg/oci/layout.go b/pkg/oci/layout.go index 53dc93a..bbb3b92 100644 --- a/pkg/oci/layout.go +++ b/pkg/oci/layout.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/docker/attest/pkg/attestation" att "github.com/docker/attest/pkg/attestation" v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/google/go-containerregistry/pkg/v1/layout" @@ -50,7 +51,7 @@ func (r *OCILayoutResolver) Attestations(ctx context.Context, predicateType stri var envs []*att.Envelope manifest := r.AttestationManifest.Manifest for i, l := range manifest.Layers { - if l.Annotations[InTotoPredicateType] != predicateType { + if l.Annotations[attestation.InTotoPredicateType] != predicateType { continue } layer := layers[i] @@ -121,7 +122,7 @@ func attestationManifestFromOCILayout(path string, platform *v1.Platform) (*Atte } } for _, mf := range mfs2.Manifests { - if mf.Annotations[att.DockerReferenceType] != AttestationManifestType { + if mf.Annotations[att.DockerReferenceType] != attestation.AttestationManifestType { continue } diff --git a/pkg/oci/oci.go b/pkg/oci/oci.go index e6480b4..255d8bf 100644 --- a/pkg/oci/oci.go +++ b/pkg/oci/oci.go @@ -57,7 +57,7 @@ func ExtractEnvelopes(ia *AttestationManifest, predicateType string) ([]*att.Env for i, l := range manifest.Layers { if (strings.HasPrefix(string(l.MediaType), "application/vnd.in-toto.")) && strings.HasSuffix(string(l.MediaType), "+dsse") && - l.Annotations[InTotoPredicateType] == predicateType { + l.Annotations[att.InTotoPredicateType] == predicateType { reader, err := layers[i].Uncompressed() if err != nil { return nil, fmt.Errorf("failed to get layer contents: %w", err) diff --git a/pkg/oci/referrers.go b/pkg/oci/referrers.go index 6629b6d..5ede960 100644 --- a/pkg/oci/referrers.go +++ b/pkg/oci/referrers.go @@ -79,7 +79,7 @@ func (r *ReferrersResolver) resolveAttestations(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to get manifest: %w", err) } - if manifest.Annotations[att.DockerReferenceType] != AttestationManifestType { + if manifest.Annotations[att.DockerReferenceType] != att.AttestationManifestType { continue } if manifest.Annotations[att.DockerReferenceDigest] != subjectDigest { diff --git a/pkg/oci/registry_test.go b/pkg/oci/registry_test.go index c33e0c1..655de82 100644 --- a/pkg/oci/registry_test.go +++ b/pkg/oci/registry_test.go @@ -29,9 +29,12 @@ func TestRegistry(t *testing.T) { Replace: true, SkipSubject: true, } - attIdx, err := oci.SubjectIndexFromPath(oci.UnsignedTestImage) + attIdx, err := oci.IndexFromPath(oci.UnsignedTestImage) require.NoError(t, err) - signedIndex, err := attest.Sign(ctx, attIdx.Index, signer, opts) + signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts) + require.NoError(t, err) + signedIndex := attIdx.Index + signedIndex, err = attestation.AddImagesToIndex(signedIndex, signedManifests) require.NoError(t, err) indexName := fmt.Sprintf("%s/repo:root", u.Host) diff --git a/pkg/oci/types.go b/pkg/oci/types.go index e9dfe29..73d5da7 100644 --- a/pkg/oci/types.go +++ b/pkg/oci/types.go @@ -11,17 +11,15 @@ import ( ) const ( - AttestationManifestType = "attestation-manifest" - InTotoPredicateType = "in-toto.io/predicate-type" - OciReferenceTarget = "org.opencontainers.image.ref.name" - LocalPrefix = "oci://" - RegistryPrefix = "docker://" - OCI SourceType = "OCI" - Docker SourceType = "Docker" + OciReferenceTarget = "org.opencontainers.image.ref.name" + LocalPrefix = "oci://" + RegistryPrefix = "docker://" + OCI SourceType = "OCI" + Docker SourceType = "Docker" ) type SourceType string -type SubjectIndex struct { +type NamedIndex struct { Index v1.ImageIndex Name string } @@ -42,7 +40,7 @@ type ImageSpec struct { Platform *v1.Platform } -func SubjectIndexFromPath(path string) (*SubjectIndex, error) { +func IndexFromPath(path string) (*NamedIndex, error) { wrapperIdx, err := layout.ImageIndexFromPath(path) if err != nil { return nil, fmt.Errorf("failed to load image index: %w", err) @@ -59,13 +57,13 @@ func SubjectIndexFromPath(path string) (*SubjectIndex, error) { if err != nil { return nil, fmt.Errorf("failed to extract ImageIndex for digest %s: %w", idxDigest.String(), err) } - return &SubjectIndex{ + return &NamedIndex{ Index: idx, Name: imageName, }, nil } -func SubjectIndexFromRemote(image string) (*SubjectIndex, error) { +func IndexFromRemote(image string) (*NamedIndex, error) { ref, err := name.ParseReference(image) if err != nil { return nil, fmt.Errorf("failed to parse image reference %s: %w", image, err) @@ -76,17 +74,17 @@ func SubjectIndexFromRemote(image string) (*SubjectIndex, error) { if err != nil { return nil, fmt.Errorf("failed to pull image %s: %w", image, err) } - return &SubjectIndex{ + return &NamedIndex{ Index: idx, Name: image, }, nil } -func LoadSubjectIndex(input *ImageSpec) (*SubjectIndex, error) { +func LoadIndex(input *ImageSpec) (*NamedIndex, error) { if input.Type == OCI { - return SubjectIndexFromPath(input.Identifier) + return IndexFromPath(input.Identifier) } else { - return SubjectIndexFromRemote(input.Identifier) + return IndexFromRemote(input.Identifier) } }