refactor! remove pkg directory (#145)
* refactor!: remove pkg directory * chore: include breaking changes in draft
This commit is contained in:
4
attestation/README.md
Normal file
4
attestation/README.md
Normal file
@@ -0,0 +1,4 @@
|
||||
## attestations
|
||||
This package is for components that deal with the creation, storage, and retrieval of signed attestions using OCI.
|
||||
|
||||
For more generic OCI components see the `oci` package.
|
||||
444
attestation/attestation.go
Normal file
444
attestation/attestation.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/attest/oci"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/empty"
|
||||
"github.com/google/go-containerregistry/pkg/v1/layout"
|
||||
"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"
|
||||
)
|
||||
|
||||
// NewManifest creates a new attestation manifest from a descriptor.
|
||||
func NewManifest(subject *v1.Descriptor) (*Manifest, error) {
|
||||
return &Manifest{
|
||||
OriginalDescriptor: &v1.Descriptor{
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
},
|
||||
OriginalLayers: []*Layer{},
|
||||
SubjectDescriptor: subject,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ManifestsFromIndex extracts all attestation manifests from an index.
|
||||
func ManifestsFromIndex(index v1.ImageIndex) ([]*Manifest, error) {
|
||||
idx, err := index.IndexManifest()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err)
|
||||
}
|
||||
subjects := make(map[string]*v1.Descriptor)
|
||||
for i := range idx.Manifests {
|
||||
subject := &idx.Manifests[i]
|
||||
subjects[subject.Digest.String()] = subject
|
||||
}
|
||||
|
||||
var attestationManifests []*Manifest
|
||||
for i := range idx.Manifests {
|
||||
desc := idx.Manifests[i]
|
||||
if desc.Annotations[DockerReferenceType] == AttestationManifestType {
|
||||
subject := subjects[desc.Annotations[DockerReferenceDigest]]
|
||||
if subject == nil {
|
||||
return nil, fmt.Errorf("failed to find subject for attestation manifest: %w", err)
|
||||
}
|
||||
attestationImage, err := index.Image(desc.Digest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", desc.Digest.String(), err)
|
||||
}
|
||||
attestationLayers, err := layersFromImage(attestationImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
|
||||
}
|
||||
attestationManifests = append(attestationManifests,
|
||||
&Manifest{
|
||||
OriginalDescriptor: &desc,
|
||||
SubjectDescriptor: subject,
|
||||
OriginalLayers: attestationLayers,
|
||||
})
|
||||
}
|
||||
}
|
||||
return attestationManifests, nil
|
||||
}
|
||||
|
||||
// LayersFromImage extracts all attestation layers from an image.
|
||||
func layersFromImage(image v1.Image) ([]*Layer, error) {
|
||||
layers, err := image.Layers()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract layers from image: %w", err)
|
||||
}
|
||||
var attestationLayers []*Layer
|
||||
for _, layer := range layers {
|
||||
// parse layer blob as json
|
||||
r, err := layer.Uncompressed()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer contents: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
mt, err := layer.MediaType()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer media type: %w", err)
|
||||
}
|
||||
layerDesc, err := partial.Descriptor(layer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get descriptor for layer: %w", err)
|
||||
}
|
||||
// copy original annotations
|
||||
ann := maps.Clone(layerDesc.Annotations)
|
||||
// only decode intoto statements
|
||||
stmt := new(intoto.Statement)
|
||||
if mt == types.MediaType(intoto.PayloadType) {
|
||||
err = json.NewDecoder(r).Decode(&stmt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode statement layer contents: %w", err)
|
||||
}
|
||||
}
|
||||
attestationLayers = append(attestationLayers, &Layer{Layer: layer, Statement: stmt, Annotations: ann})
|
||||
}
|
||||
return attestationLayers, nil
|
||||
}
|
||||
|
||||
func (manifest *Manifest) Add(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)
|
||||
}
|
||||
manifest.SignedLayers = append(manifest.SignedLayers, layer)
|
||||
return nil
|
||||
}
|
||||
|
||||
func createSignedImageLayer(ctx context.Context, statement *intoto.Statement, signer dsse.SignerVerifier, opts *SigningOptions) (*Layer, 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 &Layer{
|
||||
Statement: statement,
|
||||
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 updateImageIndex(
|
||||
idx v1.ImageIndex,
|
||||
manifest *Manifest,
|
||||
options ...func(*ManifestImageOptions) error,
|
||||
) (v1.ImageIndex, error) {
|
||||
image, err := manifest.BuildImage(options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build image: %w", err)
|
||||
}
|
||||
newDesc, err := partial.Descriptor(image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get descriptor: %w", err)
|
||||
}
|
||||
newDesc.Platform = &v1.Platform{
|
||||
Architecture: "unknown",
|
||||
OS: "unknown",
|
||||
}
|
||||
newDesc.MediaType = manifest.OriginalDescriptor.MediaType
|
||||
newDesc.Annotations = manifest.OriginalDescriptor.Annotations
|
||||
idx = mutate.RemoveManifests(idx, match.Digests(manifest.OriginalDescriptor.Digest))
|
||||
idx = mutate.AppendManifests(idx, mutate.IndexAddendum{
|
||||
Add: image,
|
||||
Descriptor: *newDesc,
|
||||
})
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func UpdateIndexImages(idx v1.ImageIndex, manifest []*Manifest, options ...func(*ManifestImageOptions) error) (v1.ImageIndex, error) {
|
||||
var err error
|
||||
for _, m := range manifest {
|
||||
idx, err = updateImageIndex(idx, m, options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add image to index: %w", err)
|
||||
}
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
func newOptions(options ...func(*ManifestImageOptions) error) (*ManifestImageOptions, error) {
|
||||
opts := &ManifestImageOptions{}
|
||||
for _, opt := range options {
|
||||
err := opt(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
func WithoutSubject(skipSubject bool) func(*ManifestImageOptions) error {
|
||||
return func(r *ManifestImageOptions) error {
|
||||
r.skipSubject = skipSubject
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithReplacedLayers(replaceLayers bool) func(*ManifestImageOptions) error {
|
||||
return func(r *ManifestImageOptions) error {
|
||||
r.replaceLayers = replaceLayers
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// build an image with signed attestations, optionally replacing existing layers with signed layers.
|
||||
func (manifest *Manifest) BuildImage(options ...func(*ManifestImageOptions) error) (v1.Image, error) {
|
||||
opts, err := newOptions(options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create options: %w", err)
|
||||
}
|
||||
resultLayers := manifest.SignedLayers
|
||||
for _, existingLayer := range manifest.OriginalLayers {
|
||||
var found bool
|
||||
for _, signedLayer := range manifest.SignedLayers {
|
||||
if existingLayer.Statement == signedLayer.Statement {
|
||||
found = true
|
||||
// copy over original annotations
|
||||
for k, v := range existingLayer.Annotations {
|
||||
signedLayer.Annotations[k] = v
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
// add existing layers if they've not been signed or we're not replacing them
|
||||
if !found || !opts.replaceLayers {
|
||||
resultLayers = append(resultLayers, existingLayer)
|
||||
}
|
||||
}
|
||||
// so that we attach all attestations to a single attestations image - as per current buildkit
|
||||
opts.laxReferrers = true
|
||||
newImg, err := buildImageFromLayers(resultLayers, manifest.OriginalDescriptor, manifest.SubjectDescriptor, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build image: %w", err)
|
||||
}
|
||||
return newImg, nil
|
||||
}
|
||||
|
||||
// build an image per attestation (layer) suitable for use as Referrers.
|
||||
func (manifest *Manifest) BuildReferringArtifacts() ([]v1.Image, error) {
|
||||
var images []v1.Image
|
||||
for _, layer := range manifest.SignedLayers {
|
||||
opts := &ManifestImageOptions{}
|
||||
newImg, err := buildImageFromLayers([]*Layer{layer}, manifest.OriginalDescriptor, manifest.SubjectDescriptor, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build image: %w", err)
|
||||
}
|
||||
images = append(images, newImg)
|
||||
}
|
||||
return images, nil
|
||||
}
|
||||
|
||||
// build an image containing only layers provided.
|
||||
func buildImageFromLayers(layers []*Layer, manifest *v1.Descriptor, subject *v1.Descriptor, opts *ManifestImageOptions) (v1.Image, error) {
|
||||
newImg := empty.Image
|
||||
var err error
|
||||
if len(layers) == 0 {
|
||||
return nil, fmt.Errorf("no layers supplied to build image")
|
||||
}
|
||||
// NB: if we add the subject before the layers, it does not end up being computed/serialized in the output for some reason
|
||||
// TODO - recreate this bug and push upstream
|
||||
for _, layer := range layers {
|
||||
add := mutate.Addendum{
|
||||
Layer: layer.Layer,
|
||||
Annotations: layer.Annotations,
|
||||
}
|
||||
newImg, err = mutate.Append(newImg, add)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to add layer to image: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// this is for attaching attestations to an attestation image in the index
|
||||
if opts.laxReferrers {
|
||||
newImg = mutate.ConfigMediaType(newImg, "application/vnd.oci.image.config.v1+json")
|
||||
} else {
|
||||
dsseMediatType, err := DSSEMediaType(layers[0].Statement.PredicateType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get DSSE media type: %w", err)
|
||||
}
|
||||
newImg = mutate.ArtifactType(newImg, dsseMediatType)
|
||||
newImg = mutate.ConfigMediaType(newImg, "application/vnd.oci.empty.v1+json")
|
||||
}
|
||||
// we need to set this even when we set the artifact type otherwise things break (even the go-container-registry client)
|
||||
// even though it's allowed to be empty by spec when setting artifact type
|
||||
newImg = mutate.MediaType(newImg, manifest.MediaType)
|
||||
|
||||
// see note above - must be added after the layers!
|
||||
if !opts.skipSubject {
|
||||
subject.Platform = nil
|
||||
ok := false
|
||||
newImg, ok = mutate.Subject(newImg, *subject).(v1.Image)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to set subject: %w", err)
|
||||
}
|
||||
}
|
||||
if !opts.laxReferrers {
|
||||
// as per https://github.com/opencontainers/image-spec/blob/main/manifest.md#guidance-for-an-empty-descriptor
|
||||
newImg = &oci.EmptyConfigImage{Image: newImg}
|
||||
}
|
||||
return newImg, nil
|
||||
}
|
||||
|
||||
func ExtractEnvelopes(manifest *Manifest, predicateType string) ([]*Envelope, error) {
|
||||
var envs []*Envelope
|
||||
dsseMediaType, err := DSSEMediaType(predicateType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get DSSE media type for predicate '%s': %w", predicateType, err)
|
||||
}
|
||||
for _, attestationLayer := range manifest.OriginalLayers {
|
||||
mt, err := attestationLayer.Layer.MediaType()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer media type: %w", err)
|
||||
}
|
||||
if string(mt) == dsseMediaType {
|
||||
reader, err := attestationLayer.Layer.Uncompressed()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer contents: %w", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
env := new(Envelope)
|
||||
err = json.NewDecoder(reader).Decode(&env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode envelope: %w", err)
|
||||
}
|
||||
envs = append(envs, env)
|
||||
}
|
||||
}
|
||||
|
||||
return envs, nil
|
||||
}
|
||||
|
||||
func ExtractStatementsFromIndex(idx v1.ImageIndex, mediaType string) ([]*AnnotatedStatement, error) {
|
||||
mfs2, err := idx.IndexManifest()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err)
|
||||
}
|
||||
|
||||
var statements []*AnnotatedStatement
|
||||
|
||||
for i := range mfs2.Manifests {
|
||||
mf := &mfs2.Manifests[i]
|
||||
if mf.Annotations[DockerReferenceType] != "attestation-manifest" {
|
||||
continue
|
||||
}
|
||||
|
||||
attestationImage, err := idx.Image(mf.Digest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", mf.Digest.String(), err)
|
||||
}
|
||||
layers, err := attestationImage.Layers()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract layers from attestation image: %w", err)
|
||||
}
|
||||
|
||||
for _, layer := range layers {
|
||||
// parse layer blob as json
|
||||
mt, err := layer.MediaType()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer media type: %w", err)
|
||||
}
|
||||
|
||||
if string(mt) != mediaType {
|
||||
continue
|
||||
}
|
||||
r, err := layer.Uncompressed()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer contents: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
inTotoStatement := new(intoto.Statement)
|
||||
var desc *v1.Descriptor
|
||||
if strings.HasSuffix(string(mt), "+dsse") {
|
||||
env := new(Envelope)
|
||||
err = json.NewDecoder(r).Decode(env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode env: %w", err)
|
||||
}
|
||||
payload, err := base64.StdEncoding.Strict().DecodeString(env.Payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode payload: %w", err)
|
||||
}
|
||||
err = json.Unmarshal([]byte(payload), inTotoStatement)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode %s statement: %w", mediaType, err)
|
||||
}
|
||||
} else {
|
||||
desc := new(v1.Descriptor)
|
||||
err = json.NewDecoder(r).Decode(desc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode statement: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
layerDesc, err := partial.Descriptor(layer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get descriptor for layer: %w", err)
|
||||
}
|
||||
annotations := make(map[string]string)
|
||||
for k, v := range layerDesc.Annotations {
|
||||
annotations[k] = v
|
||||
}
|
||||
statements = append(statements, &AnnotatedStatement{
|
||||
OCIDescriptor: desc,
|
||||
InTotoStatement: inTotoStatement,
|
||||
Annotations: annotations,
|
||||
})
|
||||
}
|
||||
}
|
||||
return statements, nil
|
||||
}
|
||||
|
||||
func ExtractAnnotatedStatements(path string, mediaType string) ([]*AnnotatedStatement, error) {
|
||||
idx, err := layout.ImageIndexFromPath(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load image index: %w", err)
|
||||
}
|
||||
|
||||
idxm, err := idx.IndexManifest()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get digest: %w", err)
|
||||
}
|
||||
idxDigest := idxm.Manifests[0].Digest
|
||||
|
||||
mfs, err := idx.ImageIndex(idxDigest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract ImageIndex for digest %s: %w", idxDigest.String(), err)
|
||||
}
|
||||
return ExtractStatementsFromIndex(mfs, mediaType)
|
||||
}
|
||||
18
attestation/attestation_test.go
Normal file
18
attestation/attestation_test.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package attestation_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/docker/attest/attestation"
|
||||
"github.com/docker/attest/internal/test"
|
||||
intoto "github.com/in-toto/in-toto-golang/in_toto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
const ExpectedStatements = 4
|
||||
|
||||
func TestExtractAnnotatedStatements(t *testing.T) {
|
||||
statements, err := attestation.ExtractAnnotatedStatements(test.UnsignedTestImage(".."), intoto.PayloadType)
|
||||
assert.NoError(t, err)
|
||||
assert.Equalf(t, len(statements), ExpectedStatements, "expected %d statement, got %d", ExpectedStatements, len(statements))
|
||||
}
|
||||
89
attestation/example_attestation_manifest_test.go
Normal file
89
attestation/example_attestation_manifest_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package attestation_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/docker/attest/attestation"
|
||||
"github.com/docker/attest/oci"
|
||||
"github.com/docker/attest/signerverifier"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
intoto "github.com/in-toto/in-toto-golang/in_toto"
|
||||
"github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common"
|
||||
)
|
||||
|
||||
func ExampleManifest() {
|
||||
// configure signerverifier
|
||||
// local signer (unsafe for production)
|
||||
signer, err := signerverifier.GenKeyPair()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// example using AWS KMS signer
|
||||
// aws_arn := "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012"
|
||||
// aws_region := "us-west-2"
|
||||
// signer, err := signerverifier.GetAWSSigner(cmd.Context(), aws_arn, aws_region)
|
||||
|
||||
// configure signing options
|
||||
opts := &attestation.SigningOptions{
|
||||
SkipTL: true, // skip trust logging to a transparency log
|
||||
}
|
||||
|
||||
ref := "docker/image-signer-verifier:latest"
|
||||
|
||||
digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
desc := &v1.Descriptor{
|
||||
Digest: digest,
|
||||
Size: 1234,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
}
|
||||
|
||||
// the in-toto statement to be signed
|
||||
statement := &intoto.Statement{
|
||||
StatementHeader: intoto.StatementHeader{
|
||||
PredicateType: attestation.VSAPredicateType,
|
||||
Subject: []intoto.Subject{{Name: ref, Digest: common.DigestSet{digest.Algorithm: digest.Hex}}},
|
||||
Type: intoto.StatementInTotoV01,
|
||||
},
|
||||
Predicate: attestation.VSAPredicate{
|
||||
Verifier: attestation.VSAVerifier{
|
||||
ID: "test-verifier",
|
||||
},
|
||||
TimeVerified: time.Now().UTC().Format(time.RFC3339),
|
||||
ResourceURI: "some-uri",
|
||||
Policy: attestation.VSAPolicy{URI: "some-uri"},
|
||||
VerificationResult: "PASSED",
|
||||
VerifiedLevels: []string{"SLSA_BUILD_LEVEL_1"},
|
||||
},
|
||||
}
|
||||
|
||||
// create a new manifest to hold the attestation
|
||||
manifest, err := attestation.NewManifest(desc)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// sign and add the attestation to the manifest
|
||||
err = manifest.Add(context.Background(), signer, statement, opts)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
output, err := oci.ParseImageSpecs("docker/image-signer-verifier-referrers:latest")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// save the manifest to the registry as a referrers artifact
|
||||
artifacts, err := manifest.BuildReferringArtifacts()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
err = oci.SaveImagesNoTag(artifacts, output)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
147
attestation/layout.go
Normal file
147
attestation/layout.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/attest/oci"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/layout"
|
||||
)
|
||||
|
||||
// implementation of Resolver that closes over attestations from an oci layout.
|
||||
type LayoutResolver struct {
|
||||
*Manifest
|
||||
*oci.ImageSpec
|
||||
}
|
||||
|
||||
func NewOCILayoutResolver(src *oci.ImageSpec) (*LayoutResolver, error) {
|
||||
r := &LayoutResolver{
|
||||
ImageSpec: src,
|
||||
}
|
||||
_, err := r.fetchManifest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *LayoutResolver) fetchManifest() (*Manifest, error) {
|
||||
if r.Manifest == nil {
|
||||
m, err := manifestFromOCILayout(r.Identifier, r.ImageSpec.Platform)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Manifest = m
|
||||
}
|
||||
|
||||
return r.Manifest, nil
|
||||
}
|
||||
|
||||
func (r *LayoutResolver) Attestations(_ context.Context, predicateType string) ([]*Envelope, error) {
|
||||
var envs []*Envelope
|
||||
dsseMediaType, err := DSSEMediaType(predicateType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get DSSE media type for predicate '%s': %w", predicateType, err)
|
||||
}
|
||||
for _, attestationLayer := range r.Manifest.OriginalLayers {
|
||||
mt, err := attestationLayer.Layer.MediaType()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer media type: %w", err)
|
||||
}
|
||||
mts := string(mt)
|
||||
if mts != dsseMediaType {
|
||||
continue
|
||||
}
|
||||
env := new(Envelope)
|
||||
// parse layer blob as json
|
||||
r, err := attestationLayer.Layer.Uncompressed()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer contents: %w", err)
|
||||
}
|
||||
defer r.Close()
|
||||
err = json.NewDecoder(r).Decode(env)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode envelope: %w", err)
|
||||
}
|
||||
envs = append(envs, env)
|
||||
}
|
||||
return envs, nil
|
||||
}
|
||||
|
||||
func (r *LayoutResolver) ImageName(_ context.Context) (string, error) {
|
||||
return r.SubjectName, nil
|
||||
}
|
||||
|
||||
func (r *LayoutResolver) ImageDescriptor(_ context.Context) (*v1.Descriptor, error) {
|
||||
return r.SubjectDescriptor, nil
|
||||
}
|
||||
|
||||
func (r *LayoutResolver) ImagePlatform(_ context.Context) (*v1.Platform, error) {
|
||||
return r.ImageSpec.Platform, nil
|
||||
}
|
||||
|
||||
func manifestFromOCILayout(path string, platform *v1.Platform) (*Manifest, error) {
|
||||
idx, err := layout.ImageIndexFromPath(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idxm, err := idx.IndexManifest()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get digest: %w", err)
|
||||
}
|
||||
|
||||
idxDescriptor := idxm.Manifests[0]
|
||||
idxDigest := idxDescriptor.Digest
|
||||
|
||||
mfs, err := idx.ImageIndex(idxDigest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract ImageIndex for digest %s: %w", idxDigest.String(), err)
|
||||
}
|
||||
mfs2, err := mfs.IndexManifest()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract IndexManifest from ImageIndex: %w", err)
|
||||
}
|
||||
var subjectDescriptor *v1.Descriptor
|
||||
for i := range mfs2.Manifests {
|
||||
manifest := &mfs2.Manifests[i]
|
||||
if manifest.Platform != nil {
|
||||
if manifest.Platform.Equals(*platform) {
|
||||
subjectDescriptor = manifest
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if subjectDescriptor == nil {
|
||||
return nil, fmt.Errorf("platform not found in index")
|
||||
}
|
||||
for i := range mfs2.Manifests {
|
||||
mf := &mfs2.Manifests[i]
|
||||
if mf.Annotations[DockerReferenceType] != AttestationManifestType {
|
||||
continue
|
||||
}
|
||||
|
||||
if mf.Annotations[DockerReferenceDigest] != subjectDescriptor.Digest.String() {
|
||||
continue
|
||||
}
|
||||
|
||||
attestationImage, err := mfs.Image(mf.Digest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract attestation image with digest %s: %w", mf.Digest.String(), err)
|
||||
}
|
||||
layers, err := layersFromImage(attestationImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
|
||||
}
|
||||
attest := &Manifest{
|
||||
OriginalLayers: layers,
|
||||
OriginalDescriptor: mf,
|
||||
SubjectName: idxDescriptor.Annotations["org.opencontainers.image.ref.name"],
|
||||
SubjectDescriptor: subjectDescriptor,
|
||||
}
|
||||
return attest, nil
|
||||
}
|
||||
return nil, fmt.Errorf("attestation manifest not found")
|
||||
}
|
||||
68
attestation/layout_test.go
Normal file
68
attestation/layout_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package attestation_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/attest"
|
||||
"github.com/docker/attest/attestation"
|
||||
"github.com/docker/attest/internal/test"
|
||||
"github.com/docker/attest/oci"
|
||||
"github.com/docker/attest/policy"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAttestationFromOCILayout(t *testing.T) {
|
||||
ctx, signer := test.Setup(t)
|
||||
outputLayout := test.CreateTempDir(t, "", "attest-oci-layout")
|
||||
|
||||
invalidPlatform := &v1.Platform{
|
||||
Architecture: "invalid",
|
||||
OS: "invalid",
|
||||
}
|
||||
|
||||
opts := &attestation.SigningOptions{}
|
||||
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage(".."))
|
||||
require.NoError(t, err)
|
||||
signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts)
|
||||
require.NoError(t, err)
|
||||
signedIndex := attIdx.Index
|
||||
signedIndex, err = attestation.UpdateIndexImages(signedIndex, signedManifests)
|
||||
require.NoError(t, err)
|
||||
spec, err := oci.ParseImageSpec(oci.LocalPrefix + outputLayout)
|
||||
require.NoError(t, err)
|
||||
err = oci.SaveIndex([]*oci.ImageSpec{spec}, signedIndex, outputLayout)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
platform *v1.Platform
|
||||
errorStr string
|
||||
}{
|
||||
{name: "nominal", platform: spec.Platform},
|
||||
{name: "invalid platform", platform: invalidPlatform, errorStr: "platform not found in index"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
spec := &oci.ImageSpec{
|
||||
Type: oci.OCI,
|
||||
Identifier: outputLayout,
|
||||
Platform: tc.platform,
|
||||
}
|
||||
resolver, err := policy.CreateImageDetailsResolver(spec)
|
||||
if tc.errorStr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.errorStr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
desc, err := resolver.ImageDescriptor(ctx)
|
||||
require.NoError(t, err)
|
||||
digest := desc.Digest.String()
|
||||
assert.True(t, strings.Contains(digest, "sha256:"))
|
||||
})
|
||||
}
|
||||
}
|
||||
50
attestation/mock.go
Normal file
50
attestation/mock.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/attest/oci"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
)
|
||||
|
||||
type MockResolver struct {
|
||||
Envs []*Envelope
|
||||
}
|
||||
|
||||
func (r MockResolver) Attestations(_ context.Context, _ string) ([]*Envelope, error) {
|
||||
return r.Envs, nil
|
||||
}
|
||||
|
||||
func (r MockResolver) ImageName(_ context.Context) (string, error) {
|
||||
return "library/alpine:latest", nil
|
||||
}
|
||||
|
||||
func (r MockResolver) ImageDescriptor(_ context.Context) (*v1.Descriptor, error) {
|
||||
digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &v1.Descriptor{
|
||||
Digest: digest,
|
||||
Size: 1234,
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r MockResolver) ImagePlatform(_ context.Context) (*v1.Platform, error) {
|
||||
return oci.ParsePlatform("linux/amd64")
|
||||
}
|
||||
|
||||
type MockRegistryResolver struct {
|
||||
Subject *v1.Descriptor
|
||||
ImageNameStr string
|
||||
*MockResolver
|
||||
}
|
||||
|
||||
func (r *MockRegistryResolver) ImageDescriptor(_ context.Context) (*v1.Descriptor, error) {
|
||||
return r.Subject, nil
|
||||
}
|
||||
|
||||
func (r *MockRegistryResolver) ImageName(_ context.Context) (string, error) {
|
||||
return r.ImageNameStr, nil
|
||||
}
|
||||
123
attestation/referrers.go
Normal file
123
attestation/referrers.go
Normal file
@@ -0,0 +1,123 @@
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/docker/attest/oci"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
)
|
||||
|
||||
type ReferrersResolver struct {
|
||||
referrersRepo string
|
||||
oci.ImageDetailsResolver
|
||||
}
|
||||
|
||||
func NewReferrersResolver(src oci.ImageDetailsResolver, options ...func(*ReferrersResolver) error) (*ReferrersResolver, error) {
|
||||
res := &ReferrersResolver{
|
||||
ImageDetailsResolver: src,
|
||||
}
|
||||
for _, opt := range options {
|
||||
err := opt(res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func WithReferrersRepo(repo string) func(*ReferrersResolver) error {
|
||||
return func(r *ReferrersResolver) error {
|
||||
r.referrersRepo = repo
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ReferrersResolver) resolveAttestations(ctx context.Context, predicateType string) ([]*Manifest, error) {
|
||||
dsseMediaType, err := DSSEMediaType(predicateType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get DSSE media type for predicate '%s': %w", predicateType, err)
|
||||
}
|
||||
imageName, err := r.ImageName(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get image name: %w", err)
|
||||
}
|
||||
subjectRef, err := name.ParseReference(imageName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse reference: %w", err)
|
||||
}
|
||||
desc, err := r.ImageDescriptor(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get descriptor: %w", err)
|
||||
}
|
||||
subjectDigest := desc.Digest.String()
|
||||
var referrersSubjectRef name.Digest
|
||||
if r.referrersRepo != "" {
|
||||
referrersSubjectRef, err = name.NewDigest(fmt.Sprintf("%s@%s", strings.TrimPrefix(r.referrersRepo, oci.RegistryPrefix), subjectDigest))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create referrers reference: %w", err)
|
||||
}
|
||||
} else {
|
||||
referrersSubjectRef = subjectRef.Context().Digest(subjectDigest)
|
||||
}
|
||||
options := oci.WithOptions(ctx, nil)
|
||||
options = append(options, remote.WithFilter("artifactType", dsseMediaType))
|
||||
referrersIndex, err := remote.Referrers(referrersSubjectRef, options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get referrers: %w", err)
|
||||
}
|
||||
referrersIndexManifest, err := referrersIndex.IndexManifest()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get index manifest: %w", err)
|
||||
}
|
||||
aManifests := make([]*Manifest, 0)
|
||||
for i := range referrersIndexManifest.Manifests {
|
||||
m := referrersIndexManifest.Manifests[i]
|
||||
remoteRef := referrersSubjectRef.Context().Digest(m.Digest.String())
|
||||
options = oci.WithOptions(ctx, nil)
|
||||
attestationImage, err := remote.Image(remoteRef, options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get referred image: %w", err)
|
||||
}
|
||||
layers, err := layersFromImage(attestationImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
|
||||
}
|
||||
if len(layers) != 1 {
|
||||
return nil, fmt.Errorf("expected exactly one layer, got %d", len(layers))
|
||||
}
|
||||
mt, err := layers[0].Layer.MediaType()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer media type: %w", err)
|
||||
}
|
||||
if string(mt) != dsseMediaType {
|
||||
return nil, fmt.Errorf("expected layer media type %s, got %s", dsseMediaType, mt)
|
||||
}
|
||||
attest := &Manifest{
|
||||
SubjectName: imageName,
|
||||
OriginalLayers: layers,
|
||||
OriginalDescriptor: &m,
|
||||
SubjectDescriptor: desc,
|
||||
}
|
||||
aManifests = append(aManifests, attest)
|
||||
}
|
||||
return aManifests, nil
|
||||
}
|
||||
|
||||
func (r *ReferrersResolver) Attestations(ctx context.Context, predicateType string) ([]*Envelope, error) {
|
||||
manifests, err := r.resolveAttestations(ctx, predicateType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve attestations: %w", err)
|
||||
}
|
||||
var envs []*Envelope
|
||||
for _, attest := range manifests {
|
||||
es, err := ExtractEnvelopes(attest, predicateType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to extract envelopes: %w", err)
|
||||
}
|
||||
envs = append(envs, es...)
|
||||
}
|
||||
return envs, nil
|
||||
}
|
||||
329
attestation/referrers_test.go
Normal file
329
attestation/referrers_test.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package attestation_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/attest"
|
||||
"github.com/docker/attest/attestation"
|
||||
"github.com/docker/attest/config"
|
||||
"github.com/docker/attest/internal/test"
|
||||
"github.com/docker/attest/oci"
|
||||
"github.com/docker/attest/policy"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
"github.com/google/go-containerregistry/pkg/registry"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
NoProvenanceImage = filepath.Join("..", "test", "testdata", "no-provenance-image")
|
||||
PassPolicyDir = filepath.Join("..", "test", "testdata", "local-policy-pass")
|
||||
LocalPolicy = filepath.Join("..", "test", "testdata", "local-policy")
|
||||
LocalPolicyAttached = filepath.Join("..", "test", "testdata", "local-policy-attached")
|
||||
PassNoTLPolicyDir = filepath.Join("..", "test", "testdata", "local-policy-no-tl")
|
||||
FailPolicyDir = filepath.Join("..", "test", "testdata", "local-policy-fail")
|
||||
TestTempDir = "attest-sign-test"
|
||||
)
|
||||
|
||||
func TestAttestationReferenceTypes(t *testing.T) {
|
||||
ctx, signer := test.Setup(t)
|
||||
platforms := []string{"linux/amd64", "linux/arm64"}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
server *httptest.Server
|
||||
referrersServer *httptest.Server
|
||||
useDigest bool
|
||||
referrersRepo string
|
||||
attestationSource config.AttestationStyle
|
||||
expectFailure bool
|
||||
}{
|
||||
{
|
||||
name: "referrers support, defaults",
|
||||
server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))),
|
||||
},
|
||||
{
|
||||
name: "use digest",
|
||||
server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))),
|
||||
useDigest: true,
|
||||
},
|
||||
{
|
||||
name: "attached attestations, referrers repo (mismatched args)",
|
||||
server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))),
|
||||
expectFailure: true, // mismatched args
|
||||
attestationSource: config.AttestationStyleAttached,
|
||||
referrersRepo: "referrers",
|
||||
},
|
||||
{
|
||||
name: "referrers attestations, referrers repo (no policy)",
|
||||
server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))),
|
||||
expectFailure: true, // no policy
|
||||
attestationSource: config.AttestationStyleReferrers,
|
||||
referrersRepo: "referrers",
|
||||
},
|
||||
{
|
||||
name: "referrers attestations",
|
||||
server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))),
|
||||
attestationSource: config.AttestationStyleReferrers,
|
||||
},
|
||||
{
|
||||
name: "referrers attestations, no referrers support on server",
|
||||
server: httptest.NewServer(registry.New(registry.WithReferrersSupport(false))),
|
||||
attestationSource: config.AttestationStyleReferrers,
|
||||
referrersServer: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))),
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := tc.server
|
||||
defer s.Close()
|
||||
|
||||
if tc.referrersServer != nil {
|
||||
defer tc.referrersServer.Close()
|
||||
}
|
||||
u, err := url.Parse(s.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := &attestation.SigningOptions{
|
||||
SkipTL: true,
|
||||
}
|
||||
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage(".."))
|
||||
require.NoError(t, err)
|
||||
|
||||
indexName := fmt.Sprintf("%s/repo:root", u.Host)
|
||||
require.NoError(t, err)
|
||||
|
||||
outputRepo := indexName
|
||||
if tc.referrersServer != nil {
|
||||
ru, err := url.Parse(s.URL)
|
||||
require.NoError(t, err)
|
||||
tc.referrersRepo = fmt.Sprintf("%s/referrers", ru.Host)
|
||||
outputRepo = tc.referrersRepo
|
||||
}
|
||||
// sign all the statements in the index
|
||||
signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// push subject image so that it can be resolved
|
||||
require.NoError(t, err)
|
||||
err = oci.PushIndexToRegistry(attIdx.Index, indexName)
|
||||
require.NoError(t, err)
|
||||
|
||||
// upload referrers
|
||||
output, err := oci.ParseImageSpec(outputRepo)
|
||||
require.NoError(t, err)
|
||||
for _, attIdx := range signedManifests {
|
||||
images, err := attIdx.BuildReferringArtifacts()
|
||||
require.NoError(t, err)
|
||||
err = oci.SaveImagesNoTag(images, []*oci.ImageSpec{output})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
for _, platform := range platforms {
|
||||
// can eval policy in the normal way
|
||||
ref := indexName
|
||||
if tc.useDigest {
|
||||
options := oci.WithOptions(ctx, nil)
|
||||
subjectRef, err := name.ParseReference(indexName)
|
||||
require.NoError(t, err)
|
||||
desc, err := remote.Index(subjectRef, options...)
|
||||
require.NoError(t, err)
|
||||
idxDigest, err := desc.Digest()
|
||||
require.NoError(t, err)
|
||||
ref = fmt.Sprintf("%s/repo@%s", u.Host, idxDigest.String())
|
||||
}
|
||||
|
||||
policyOpts := &policy.Options{
|
||||
LocalPolicyDir: LocalPolicy,
|
||||
DisableTUF: true,
|
||||
}
|
||||
|
||||
if tc.referrersRepo != "" {
|
||||
policyOpts.ReferrersRepo = tc.referrersRepo
|
||||
}
|
||||
|
||||
if tc.attestationSource != "" {
|
||||
policyOpts.AttestationStyle = tc.attestationSource
|
||||
}
|
||||
src, err := oci.ParseImageSpec(ref, oci.WithPlatform(platform))
|
||||
require.NoError(t, err)
|
||||
results, err := attest.Verify(ctx, src, policyOpts)
|
||||
if tc.expectFailure {
|
||||
require.Error(t, err)
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, attest.OutcomeSuccess, results.Outcome)
|
||||
|
||||
if tc.useDigest {
|
||||
p, err := oci.ParsePlatform(platform)
|
||||
require.NoError(t, err)
|
||||
options := oci.WithOptions(ctx, p)
|
||||
subjectRef, err := name.ParseReference(indexName)
|
||||
require.NoError(t, err)
|
||||
desc, err := remote.Image(subjectRef, options...)
|
||||
require.NoError(t, err)
|
||||
subjectDigest, err := desc.Digest()
|
||||
require.NoError(t, err)
|
||||
ref = fmt.Sprintf("%s/repo@%s", u.Host, subjectDigest.String())
|
||||
}
|
||||
src, err = oci.ParseImageSpec(ref, oci.WithPlatform(platform))
|
||||
require.NoError(t, err)
|
||||
results, err = attest.Verify(ctx, src, policyOpts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, attest.OutcomeSuccess, results.Outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencesInDifferentRepo(t *testing.T) {
|
||||
ctx, signer := test.Setup(t)
|
||||
repoName := "repo"
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
server *httptest.Server
|
||||
refServer *httptest.Server
|
||||
}{
|
||||
{
|
||||
name: "referrers support",
|
||||
server: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))),
|
||||
refServer: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))),
|
||||
},
|
||||
{
|
||||
name: "no referrers support",
|
||||
server: httptest.NewServer(registry.New()),
|
||||
refServer: httptest.NewServer(registry.New(registry.WithReferrersSupport(true))),
|
||||
},
|
||||
} {
|
||||
server := tc.server
|
||||
defer server.Close()
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
refServer := tc.refServer
|
||||
defer refServer.Close()
|
||||
refServerURL, err := url.Parse(refServer.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := &attestation.SigningOptions{
|
||||
SkipTL: true,
|
||||
}
|
||||
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage(".."))
|
||||
require.NoError(t, err)
|
||||
|
||||
indexName := fmt.Sprintf("%s/%s:latest", serverURL.Host, repoName)
|
||||
err = oci.PushIndexToRegistry(attIdx.Index, indexName)
|
||||
require.NoError(t, err)
|
||||
|
||||
signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// push signed attestation image to the ref server
|
||||
for _, signedManifest := range signedManifests {
|
||||
// push references using subject-digest.att convention
|
||||
image, err := signedManifest.BuildImage()
|
||||
require.NoError(t, err)
|
||||
err = oci.PushImageToRegistry(image, fmt.Sprintf("%s/%s:tag-does-not-matter", refServerURL.Host, repoName))
|
||||
require.NoError(t, err)
|
||||
|
||||
refServer := tc.refServer
|
||||
defer refServer.Close()
|
||||
refServerURL, err := url.Parse(refServer.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := &attestation.SigningOptions{
|
||||
SkipTL: true,
|
||||
}
|
||||
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage(".."))
|
||||
require.NoError(t, err)
|
||||
|
||||
indexName := fmt.Sprintf("%s/%s:latest", serverURL.Host, repoName)
|
||||
err = oci.PushIndexToRegistry(attIdx.Index, indexName)
|
||||
require.NoError(t, err)
|
||||
|
||||
signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// push signed attestation image to the ref server
|
||||
for _, mf := range signedManifests {
|
||||
// push references using subject-digest.att convention
|
||||
imgs, err := mf.BuildReferringArtifacts()
|
||||
require.NoError(t, err)
|
||||
for _, img := range imgs {
|
||||
err = oci.PushImageToRegistry(img, fmt.Sprintf("%s/%s:tag-does-not-matter", refServerURL.Host, repoName))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
mfs2, err := attIdx.Index.IndexManifest()
|
||||
require.NoError(t, err)
|
||||
for _, mf := range mfs2.Manifests {
|
||||
// skip signed/unsigned attestations
|
||||
if mf.Annotations[attestation.DockerReferenceType] == attestation.AttestationManifestType {
|
||||
continue
|
||||
}
|
||||
// can evaluate policy using referrers in a different repo
|
||||
referencedImage := fmt.Sprintf("%s@%s", indexName, mf.Digest.String())
|
||||
policyOpts := &policy.Options{
|
||||
LocalPolicyDir: PassPolicyDir,
|
||||
DisableTUF: true,
|
||||
}
|
||||
src, err := oci.ParseImageSpec(referencedImage)
|
||||
require.NoError(t, err)
|
||||
results, err := attest.Verify(ctx, src, policyOpts)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, attest.OutcomeSuccess, results.Outcome)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrectArtifactTypeInTagFallback(t *testing.T) {
|
||||
ctx, signer := test.Setup(t)
|
||||
server := httptest.NewServer(registry.New())
|
||||
|
||||
defer server.Close()
|
||||
serverURL, err := url.Parse(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
repoName := "repo"
|
||||
|
||||
opts := &attestation.SigningOptions{
|
||||
SkipTL: true,
|
||||
}
|
||||
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage(".."))
|
||||
require.NoError(t, err)
|
||||
|
||||
indexName := fmt.Sprintf("%s/%s:latest", serverURL.Host, repoName)
|
||||
err = oci.PushIndexToRegistry(attIdx.Index, indexName)
|
||||
require.NoError(t, err)
|
||||
|
||||
signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// this should create and maintain an index of referrers
|
||||
for _, mf := range signedManifests {
|
||||
imgs, err := mf.BuildReferringArtifacts()
|
||||
require.NoError(t, err)
|
||||
for _, img := range imgs {
|
||||
err = oci.PushImageToRegistry(img, fmt.Sprintf("%s/%s:tag-does-not-matter", serverURL.Host, repoName))
|
||||
require.NoError(t, err)
|
||||
mf, err := img.Manifest()
|
||||
require.NoError(t, err)
|
||||
subject := mf.Subject
|
||||
subjectRef, err := name.ParseReference(fmt.Sprintf("%s/%s:sha256-%s", serverURL.Host, repoName, subject.Digest.Hex))
|
||||
require.NoError(t, err)
|
||||
idx, err := remote.Index(subjectRef)
|
||||
require.NoError(t, err)
|
||||
imf, err := idx.IndexManifest()
|
||||
require.NoError(t, err)
|
||||
for _, m := range imf.Manifests {
|
||||
assert.Contains(t, m.ArtifactType, "application/vnd.in-toto")
|
||||
assert.Contains(t, m.ArtifactType, "+dsse")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
attestation/registry.go
Normal file
101
attestation/registry.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/attest/oci"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
)
|
||||
|
||||
type RegistryResolver struct {
|
||||
*oci.RegistryImageDetailsResolver
|
||||
*Manifest
|
||||
}
|
||||
|
||||
func NewRegistryResolver(src *oci.RegistryImageDetailsResolver) (*RegistryResolver, error) {
|
||||
return &RegistryResolver{
|
||||
RegistryImageDetailsResolver: src,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *RegistryResolver) Attestations(ctx context.Context, predicateType string) ([]*Envelope, error) {
|
||||
if r.Manifest == nil {
|
||||
attest, err := FetchManifest(ctx, r.Identifier, r.ImageSpec.Platform)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Manifest = attest
|
||||
}
|
||||
return ExtractEnvelopes(r.Manifest, predicateType)
|
||||
}
|
||||
|
||||
func attestationDigestForImage(ix *v1.IndexManifest, imageDigest string, attestType string) (string, error) {
|
||||
for i := range ix.Manifests {
|
||||
m := &ix.Manifests[i]
|
||||
if v, ok := m.Annotations[DockerReferenceType]; ok && v == attestType {
|
||||
if d, ok := m.Annotations[DockerReferenceDigest]; ok && d == imageDigest {
|
||||
return m.Digest.String(), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no attestation found for image %s", imageDigest)
|
||||
}
|
||||
|
||||
func FetchManifest(ctx context.Context, image string, platform *v1.Platform) (*Manifest, error) {
|
||||
// we want to get to the image index, so ignoring platform for now
|
||||
options := oci.WithOptions(ctx, nil)
|
||||
ref, err := name.ParseReference(image)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse reference: %w", err)
|
||||
}
|
||||
index, err := remote.Index(ref, options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get index: %w", err)
|
||||
}
|
||||
indexManifest, err := index.IndexManifest()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get index manifest: %w", err)
|
||||
}
|
||||
subjectDescriptor, err := oci.ImageDescriptor(indexManifest, platform)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to obtain image for platform: %w", err)
|
||||
}
|
||||
|
||||
digest := subjectDescriptor.Digest.String()
|
||||
ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), digest))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse attestation reference: %w", err)
|
||||
}
|
||||
|
||||
attestationDigest, err := attestationDigestForImage(indexManifest, digest, "attestation-manifest")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to obtain attestation for image: %w", err)
|
||||
}
|
||||
ref, err = name.ParseReference(fmt.Sprintf("%s@%s", ref.Context().Name(), attestationDigest))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse attestation reference: %w", err)
|
||||
}
|
||||
remoteDescriptor, err := remote.Get(ref, options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get attestation: %w", err)
|
||||
}
|
||||
attestationImage, err := remoteDescriptor.Image()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get attestation image: %w", err)
|
||||
}
|
||||
|
||||
layers, err := layersFromImage(attestationImage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get attestations from image: %w", err)
|
||||
}
|
||||
attest := &Manifest{
|
||||
OriginalLayers: layers,
|
||||
OriginalDescriptor: &remoteDescriptor.Descriptor,
|
||||
SubjectName: image,
|
||||
SubjectDescriptor: subjectDescriptor,
|
||||
}
|
||||
return attest, nil
|
||||
}
|
||||
50
attestation/registry_test.go
Normal file
50
attestation/registry_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package attestation_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/attest"
|
||||
"github.com/docker/attest/attestation"
|
||||
"github.com/docker/attest/internal/test"
|
||||
"github.com/docker/attest/oci"
|
||||
"github.com/docker/attest/policy"
|
||||
"github.com/google/go-containerregistry/pkg/registry"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRegistry(t *testing.T) {
|
||||
ctx, signer := test.Setup(t)
|
||||
server := httptest.NewServer(registry.New(registry.WithReferrersSupport(false)))
|
||||
defer server.Close()
|
||||
u, err := url.Parse(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
opts := &attestation.SigningOptions{}
|
||||
attIdx, err := oci.IndexFromPath(test.UnsignedTestImage(".."))
|
||||
require.NoError(t, err)
|
||||
signedManifests, err := attest.SignStatements(ctx, attIdx.Index, signer, opts)
|
||||
require.NoError(t, err)
|
||||
signedIndex := attIdx.Index
|
||||
signedIndex, err = attestation.UpdateIndexImages(signedIndex, signedManifests)
|
||||
require.NoError(t, err)
|
||||
|
||||
indexName := fmt.Sprintf("%s/repo:root", u.Host)
|
||||
require.NoError(t, err)
|
||||
err = oci.PushIndexToRegistry(signedIndex, indexName)
|
||||
require.NoError(t, err)
|
||||
|
||||
spec, err := oci.ParseImageSpec(indexName)
|
||||
require.NoError(t, err)
|
||||
|
||||
resolver, err := policy.CreateImageDetailsResolver(spec)
|
||||
require.NoError(t, err)
|
||||
desc, err := resolver.ImageDescriptor(ctx)
|
||||
require.NoError(t, err)
|
||||
digest := desc.Digest.String()
|
||||
assert.True(t, strings.Contains(digest, "sha256:"))
|
||||
}
|
||||
12
attestation/resolver.go
Normal file
12
attestation/resolver.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/docker/attest/oci"
|
||||
)
|
||||
|
||||
type Resolver interface {
|
||||
oci.ImageDetailsResolver
|
||||
Attestations(ctx context.Context, mediaType string) ([]*Envelope, error)
|
||||
}
|
||||
77
attestation/sign.go
Normal file
77
attestation/sign.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/docker/attest/internal/util"
|
||||
"github.com/docker/attest/tlog"
|
||||
intoto "github.com/in-toto/in-toto-golang/in_toto"
|
||||
"github.com/secure-systems-lab/go-securesystemslib/dsse"
|
||||
)
|
||||
|
||||
// SignDSSE signs a payload with a given signer and uploads the signature to the transparency log.
|
||||
func SignDSSE(ctx context.Context, payload []byte, signer dsse.SignerVerifier, opts *SigningOptions) (*Envelope, error) {
|
||||
payloadType := intoto.PayloadType
|
||||
env := new(Envelope)
|
||||
env.Payload = base64Encoding.EncodeToString(payload)
|
||||
env.PayloadType = payloadType
|
||||
encPayload := dsse.PAE(payloadType, payload)
|
||||
|
||||
// statement message digest
|
||||
hash := util.SHA256(encPayload)
|
||||
|
||||
// sign message digest
|
||||
sig, err := signer.Sign(ctx, hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error signing attestation: %w", err)
|
||||
}
|
||||
|
||||
// get Key ID from signer
|
||||
keyID, err := signer.KeyID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting public key ID: %w", err)
|
||||
}
|
||||
|
||||
dsseSig := &Signature{
|
||||
KeyID: keyID,
|
||||
Sig: base64Encoding.EncodeToString(sig),
|
||||
}
|
||||
if !opts.SkipTL {
|
||||
ext, err := logSignature(ctx, tlog.GetTL(ctx), &sig, &encPayload, signer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to log to rekor: %w", err)
|
||||
}
|
||||
dsseSig.Extension = ext
|
||||
}
|
||||
// add signature to dsse envelope
|
||||
env.Signatures = []*Signature{dsseSig}
|
||||
|
||||
return env, nil
|
||||
}
|
||||
|
||||
// returns a new envelope with the transparency log entry added to the signature extension.
|
||||
func logSignature(ctx context.Context, t tlog.TL, sig *[]byte, encPayload *[]byte, signer dsse.SignerVerifier) (*Extension, error) {
|
||||
// get Key ID from signer
|
||||
keyID, err := signer.KeyID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting public key ID: %w", err)
|
||||
}
|
||||
entry, err := t.UploadLogEntry(ctx, keyID, *encPayload, *sig, signer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error uploading TL entry: %w", err)
|
||||
}
|
||||
entryObj, err := t.UnmarshalEntry(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error unmarshaling tl entry: %w", err)
|
||||
}
|
||||
return &Extension{
|
||||
Kind: DockerDSSEExtKind,
|
||||
Ext: &DockerDSSEExtension{
|
||||
TL: &DockerTLExtension{
|
||||
Kind: RekorTLExtKind,
|
||||
Data: entryObj, // transparency log entry metadata
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
301
attestation/sign_test.go
Normal file
301
attestation/sign_test.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package attestation_test
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/docker/attest/attestation"
|
||||
"github.com/docker/attest/internal/test"
|
||||
"github.com/docker/attest/oci"
|
||||
"github.com/docker/attest/signerverifier"
|
||||
"github.com/google/go-containerregistry/pkg/registry"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"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/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSignVerifyAttestation(t *testing.T) {
|
||||
ctx, signer := test.Setup(t)
|
||||
stmt := &intoto.Statement{
|
||||
StatementHeader: intoto.StatementHeader{
|
||||
Type: intoto.StatementInTotoV01,
|
||||
PredicateType: intoto.PredicateSPDX,
|
||||
},
|
||||
Predicate: "test",
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(stmt)
|
||||
require.NoError(t, err)
|
||||
opts := &attestation.SigningOptions{}
|
||||
env, err := attestation.SignDSSE(ctx, payload, signer, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// marshal envelope to json to test for bugs when marshaling envelope data
|
||||
serializedEnv, err := json.Marshal(env)
|
||||
require.NoError(t, err)
|
||||
deserializedEnv := new(attestation.Envelope)
|
||||
err = json.Unmarshal(serializedEnv, deserializedEnv)
|
||||
require.NoError(t, err)
|
||||
|
||||
// signer.Public() calls AWS API when using AWS signer, use attestation.GetPublicVerificationKey() to get key from TUF repo
|
||||
// signer.Public() used here for test purposes
|
||||
ecPub, ok := signer.Public().(*ecdsa.PublicKey)
|
||||
assert.True(t, ok)
|
||||
pem, err := signerverifier.ConvertToPEM(ecPub)
|
||||
assert.NoError(t, err)
|
||||
keyID, err := signerverifier.KeyID(ecPub)
|
||||
assert.NoError(t, err)
|
||||
|
||||
badKeyPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
badKey := &badKeyPriv.PublicKey
|
||||
badPEM, err := signerverifier.ConvertToPEM(badKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
keyID string
|
||||
pem []byte
|
||||
distrust bool
|
||||
from time.Time
|
||||
to *time.Time
|
||||
status string
|
||||
expectedError string
|
||||
}{
|
||||
{
|
||||
name: "all OK",
|
||||
keyID: keyID,
|
||||
pem: pem,
|
||||
distrust: false,
|
||||
from: time.Time{},
|
||||
to: nil,
|
||||
status: "active",
|
||||
expectedError: "",
|
||||
},
|
||||
{
|
||||
name: "key not found",
|
||||
keyID: "someotherkey",
|
||||
pem: pem,
|
||||
distrust: false,
|
||||
from: time.Time{},
|
||||
to: nil,
|
||||
status: "active",
|
||||
expectedError: fmt.Sprintf("key not found: %s", keyID),
|
||||
},
|
||||
{
|
||||
name: "key distrusted",
|
||||
keyID: keyID,
|
||||
pem: pem,
|
||||
distrust: true,
|
||||
from: time.Time{},
|
||||
to: nil,
|
||||
status: "active",
|
||||
expectedError: "distrusted",
|
||||
},
|
||||
{
|
||||
name: "key not yet valid",
|
||||
keyID: keyID,
|
||||
pem: pem,
|
||||
distrust: false,
|
||||
from: time.Now().Add(time.Hour),
|
||||
to: nil,
|
||||
status: "active",
|
||||
expectedError: "not yet valid",
|
||||
},
|
||||
{
|
||||
name: "key already revoked",
|
||||
keyID: keyID,
|
||||
pem: pem,
|
||||
distrust: false,
|
||||
from: time.Time{},
|
||||
to: new(time.Time),
|
||||
status: "revoked",
|
||||
expectedError: "already revoked",
|
||||
},
|
||||
{
|
||||
name: "bad key",
|
||||
keyID: keyID,
|
||||
pem: badPEM,
|
||||
distrust: false,
|
||||
from: time.Time{},
|
||||
to: nil,
|
||||
status: "active",
|
||||
expectedError: "signature is not valid",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
keyMeta := &attestation.KeyMetadata{
|
||||
ID: tc.keyID,
|
||||
PEM: string(tc.pem),
|
||||
Distrust: tc.distrust,
|
||||
From: tc.from,
|
||||
To: tc.to,
|
||||
Status: tc.status,
|
||||
}
|
||||
opts := &attestation.VerifyOptions{
|
||||
Keys: attestation.Keys{keyMeta},
|
||||
}
|
||||
_, err = attestation.VerifyDSSE(ctx, deserializedEnv, opts)
|
||||
if tc.expectedError != "" {
|
||||
assert.Contains(t, err.Error(), tc.expectedError)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddSignedLayerAnnotations(t *testing.T) {
|
||||
ctx, signer := test.Setup(t)
|
||||
testCases := []struct {
|
||||
name string
|
||||
replace bool
|
||||
}{
|
||||
{"replaced", true},
|
||||
{"not replaced", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data := []byte("signed")
|
||||
testLayer := static.NewLayer(data, types.MediaType(intoto.PayloadType))
|
||||
mediaType := types.OCIManifestSchema1
|
||||
opts := &attestation.SigningOptions{}
|
||||
originalLayer := &attestation.Layer{
|
||||
Layer: testLayer,
|
||||
Statement: &intoto.Statement{
|
||||
StatementHeader: intoto.StatementHeader{
|
||||
PredicateType: attestation.VSAPredicateType,
|
||||
},
|
||||
},
|
||||
Annotations: map[string]string{"test": "test"},
|
||||
}
|
||||
|
||||
manifest := &attestation.Manifest{
|
||||
OriginalDescriptor: &v1.Descriptor{
|
||||
MediaType: mediaType,
|
||||
},
|
||||
OriginalLayers: []*attestation.Layer{
|
||||
originalLayer,
|
||||
},
|
||||
SubjectDescriptor: &v1.Descriptor{},
|
||||
}
|
||||
err := manifest.Add(ctx, signer, originalLayer.Statement, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
newImg, err := manifest.BuildImage(attestation.WithReplacedLayers(tc.replace))
|
||||
require.NoError(t, err)
|
||||
mf, _ := newImg.RawManifest()
|
||||
type Annotations struct {
|
||||
Annotations map[string]string `json:"annotations"`
|
||||
}
|
||||
type Layers struct {
|
||||
Layers []Annotations `json:"layers"`
|
||||
}
|
||||
l := &Layers{}
|
||||
err = json.Unmarshal(mf, l)
|
||||
require.NoError(t, err)
|
||||
_, ok := l.Layers[0].Annotations["test"]
|
||||
assert.Truef(t, ok, "missing annotations")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleStatementSigning(t *testing.T) {
|
||||
ctx, signer := test.Setup(t)
|
||||
empty := types.MediaType("application/vnd.oci.empty.v1+json")
|
||||
testCases := []struct {
|
||||
name string
|
||||
replace bool
|
||||
}{
|
||||
{"replaced", true},
|
||||
{"not replaced", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
opts := &attestation.SigningOptions{}
|
||||
statement := &intoto.Statement{
|
||||
StatementHeader: intoto.StatementHeader{
|
||||
PredicateType: attestation.VSAPredicateType,
|
||||
},
|
||||
}
|
||||
statement2 := &intoto.Statement{
|
||||
StatementHeader: intoto.StatementHeader{
|
||||
PredicateType: attestation.VSAPredicateType,
|
||||
},
|
||||
}
|
||||
digest, err := v1.NewHash("sha256:da8b190665956ea07890a0273e2a9c96bfe291662f08e2860e868eef69c34620")
|
||||
require.NoError(t, err)
|
||||
subject := &v1.Descriptor{
|
||||
MediaType: "application/vnd.oci.image.manifest.v1+json",
|
||||
Digest: digest,
|
||||
}
|
||||
manifest, err := attestation.NewManifest(subject)
|
||||
require.NoError(t, err)
|
||||
err = manifest.Add(ctx, signer, statement, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = manifest.Add(ctx, signer, statement2, opts)
|
||||
require.NoError(t, err)
|
||||
|
||||
// fake that the manfifest was loaded from a real image
|
||||
manifest.OriginalLayers = manifest.SignedLayers
|
||||
envelopes, err := attestation.ExtractEnvelopes(manifest, attestation.VSAPredicateType)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, envelopes, 2)
|
||||
|
||||
newImg, err := manifest.BuildImage(attestation.WithReplacedLayers(tc.replace))
|
||||
require.NoError(t, err)
|
||||
layers, err := newImg.Layers()
|
||||
require.NoError(t, err)
|
||||
if tc.replace {
|
||||
assert.Len(t, layers, 2)
|
||||
} else {
|
||||
assert.Len(t, layers, 4)
|
||||
}
|
||||
|
||||
newImgs, err := manifest.BuildReferringArtifacts()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, newImgs, 2)
|
||||
for _, img := range newImgs {
|
||||
mf, err := img.Manifest()
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, mf.ArtifactType, "application/vnd.in-toto")
|
||||
assert.Contains(t, mf.ArtifactType, "+dsse")
|
||||
assert.Equal(t, subject.MediaType, mf.MediaType)
|
||||
assert.Equal(t, empty, mf.Config.MediaType)
|
||||
assert.Equal(t, int64(2), mf.Config.Size)
|
||||
assert.Equal(t, "{}", string(mf.Config.Data))
|
||||
layers, err := img.Layers()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, layers, 1)
|
||||
}
|
||||
server := httptest.NewServer(registry.New(registry.WithReferrersSupport(true)))
|
||||
defer server.Close()
|
||||
|
||||
u, err := url.Parse(server.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
indexName := fmt.Sprintf("%s/repo:root", u.Host)
|
||||
output, err := oci.ParseImageSpecs(indexName)
|
||||
require.NoError(t, err)
|
||||
artifacts, err := manifest.BuildReferringArtifacts()
|
||||
require.NoError(t, err)
|
||||
err = oci.SaveImagesNoTag(artifacts, output)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
113
attestation/types.go
Normal file
113
attestation/types.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
intoto "github.com/in-toto/in-toto-golang/in_toto"
|
||||
v02 "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2"
|
||||
ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
)
|
||||
|
||||
const (
|
||||
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()
|
||||
|
||||
type Layer struct {
|
||||
Statement *intoto.Statement
|
||||
Layer v1.Layer
|
||||
Annotations map[string]string
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
OriginalDescriptor *v1.Descriptor
|
||||
OriginalLayers []*Layer
|
||||
|
||||
// accumulated during signing
|
||||
SignedLayers []*Layer
|
||||
// details of subject image
|
||||
SubjectName string
|
||||
SubjectDescriptor *v1.Descriptor
|
||||
}
|
||||
|
||||
type ManifestImageOptions struct {
|
||||
// how to output the image
|
||||
skipSubject bool
|
||||
replaceLayers bool
|
||||
laxReferrers bool
|
||||
}
|
||||
|
||||
// the following types are needed until https://github.com/secure-systems-lab/dsse/pull/61 is merged.
|
||||
type Envelope struct {
|
||||
PayloadType string `json:"payloadType"`
|
||||
Payload string `json:"payload"`
|
||||
Signatures []*Signature `json:"signatures"`
|
||||
}
|
||||
type Signature struct {
|
||||
KeyID string `json:"keyid"`
|
||||
Sig string `json:"sig"`
|
||||
Extension *Extension `json:"extension,omitempty"`
|
||||
}
|
||||
type Extension struct {
|
||||
Kind string `json:"kind"`
|
||||
Ext *DockerDSSEExtension `json:"ext"`
|
||||
}
|
||||
|
||||
type AnnotatedStatement struct {
|
||||
OCIDescriptor *v1.Descriptor
|
||||
InTotoStatement *intoto.Statement
|
||||
Annotations map[string]string
|
||||
}
|
||||
|
||||
type DockerDSSEExtension struct {
|
||||
TL *DockerTLExtension `json:"tl"`
|
||||
}
|
||||
|
||||
type DockerTLExtension struct {
|
||||
Kind string `json:"kind"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
type VerifyOptions struct {
|
||||
Keys []*KeyMetadata `json:"keys"`
|
||||
SkipTL bool `json:"skip_tl"`
|
||||
}
|
||||
|
||||
type SigningOptions struct {
|
||||
// don't log to the configured transparency log
|
||||
SkipTL bool
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
NoReferrers bool
|
||||
Attach bool
|
||||
ReferrersRepo string
|
||||
}
|
||||
|
||||
func DSSEMediaType(predicateType string) (string, error) {
|
||||
var predicateName string
|
||||
switch predicateType {
|
||||
case v02.PredicateSLSAProvenance:
|
||||
predicateName = "provenance"
|
||||
case intoto.PredicateSPDX:
|
||||
predicateName = "spdx"
|
||||
case VSAPredicateType:
|
||||
predicateName = "verification_summary"
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown predicate type %q", predicateType)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("application/vnd.in-toto.%s+dsse", predicateName), nil
|
||||
}
|
||||
138
attestation/verify.go
Normal file
138
attestation/verify.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/docker/attest/internal/util"
|
||||
"github.com/docker/attest/signerverifier"
|
||||
"github.com/docker/attest/tlog"
|
||||
intoto "github.com/in-toto/in-toto-golang/in_toto"
|
||||
ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/secure-systems-lab/go-securesystemslib/dsse"
|
||||
)
|
||||
|
||||
type KeyMetadata struct {
|
||||
ID string `json:"id"`
|
||||
PEM string `json:"key"`
|
||||
From time.Time `json:"from"`
|
||||
To *time.Time `json:"to"`
|
||||
Status string `json:"status"`
|
||||
SigningFormat string `json:"signing-format"`
|
||||
Distrust bool `json:"distrust,omitempty"`
|
||||
}
|
||||
|
||||
type (
|
||||
Keys []*KeyMetadata
|
||||
KeysMap map[string]*KeyMetadata
|
||||
)
|
||||
|
||||
func VerifyDSSE(ctx context.Context, env *Envelope, opts *VerifyOptions) ([]byte, error) {
|
||||
// enforce payload type
|
||||
if !ValidPayloadType(env.PayloadType) {
|
||||
return nil, fmt.Errorf("unsupported payload type %s", env.PayloadType)
|
||||
}
|
||||
|
||||
if len(env.Signatures) == 0 {
|
||||
return nil, fmt.Errorf("no signatures found")
|
||||
}
|
||||
|
||||
payload, err := base64Encoding.DecodeString(env.Payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error failed to decode payload: %w", err)
|
||||
}
|
||||
|
||||
encPayload := dsse.PAE(env.PayloadType, payload)
|
||||
|
||||
// verify signatures and transparency log entry
|
||||
for _, sig := range env.Signatures {
|
||||
err := verifySignature(ctx, sig, encPayload, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func verifySignature(ctx context.Context, sig *Signature, payload []byte, opts *VerifyOptions) error {
|
||||
keys := make(map[string]*KeyMetadata, len(opts.Keys))
|
||||
for _, key := range opts.Keys {
|
||||
keys[key.ID] = key
|
||||
}
|
||||
keyMeta, ok := keys[sig.KeyID]
|
||||
if !ok {
|
||||
return fmt.Errorf("error key not found: %s", sig.KeyID)
|
||||
}
|
||||
|
||||
if keyMeta.Distrust {
|
||||
return fmt.Errorf("key %s is distrusted", keyMeta.ID)
|
||||
}
|
||||
// TODO: this is unmarshalling with MarshalPKIXPublicKey only for us to marshal it again
|
||||
publicKey, err := signerverifier.ParsePublicKey([]byte(keyMeta.PEM))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
|
||||
if !opts.SkipTL {
|
||||
t := tlog.GetTL(ctx)
|
||||
if sig.Extension == nil || sig.Extension.Kind == "" {
|
||||
return fmt.Errorf("error missing signature extension")
|
||||
}
|
||||
if sig.Extension.Kind != DockerDSSEExtKind {
|
||||
return fmt.Errorf("error unsupported signature extension kind: %s", sig.Extension.Kind)
|
||||
}
|
||||
|
||||
// verify TL entry
|
||||
if sig.Extension.Ext.TL.Kind != RekorTLExtKind {
|
||||
return fmt.Errorf("error unsupported TL extension kind: %s", sig.Extension.Ext.TL.Kind)
|
||||
}
|
||||
entry := sig.Extension.Ext.TL.Data
|
||||
entryBytes, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal TL entry: %w", err)
|
||||
}
|
||||
|
||||
integratedTime, err := t.VerifyLogEntry(ctx, entryBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("TL entry failed verification: %w", err)
|
||||
}
|
||||
if integratedTime.Before(keyMeta.From) {
|
||||
return fmt.Errorf("key %s was not yet valid at TL log time %s (key valid from %s)", keyMeta.ID, integratedTime, keyMeta.From)
|
||||
}
|
||||
if keyMeta.To != nil && !integratedTime.Before(*keyMeta.To) {
|
||||
return fmt.Errorf("key %s was already %s at TL log time %s (key %s at %s)", keyMeta.ID, keyMeta.Status, integratedTime, keyMeta.Status, *keyMeta.To)
|
||||
}
|
||||
// verify TL entry payload
|
||||
encodedPub, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error failed to marshal public key: %w", err)
|
||||
}
|
||||
err = t.VerifyEntryPayload(entryBytes, payload, encodedPub)
|
||||
if err != nil {
|
||||
return fmt.Errorf("TL entry failed payload verification: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// decode signature
|
||||
signature, err := base64.StdEncoding.Strict().DecodeString(sig.Sig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error failed to decode signature: %w", err)
|
||||
}
|
||||
// verify payload ecdsa signature
|
||||
ok = ecdsa.VerifyASN1(publicKey, util.SHA256(payload), signature)
|
||||
if !ok {
|
||||
return fmt.Errorf("payload signature is not valid")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidPayloadType(payloadType string) bool {
|
||||
return payloadType == intoto.PayloadType || payloadType == ociv1.MediaTypeDescriptor
|
||||
}
|
||||
49
attestation/verify_test.go
Normal file
49
attestation/verify_test.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package attestation_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
|
||||
"github.com/docker/attest/attestation"
|
||||
"github.com/docker/attest/internal/test"
|
||||
intoto "github.com/in-toto/in-toto-golang/in_toto"
|
||||
ociv1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidPayloadType(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
payloadType string
|
||||
expected bool
|
||||
}{
|
||||
{"valid in-toto payload type", intoto.PayloadType, true},
|
||||
{"valid oci descriptor payload type", ociv1.MediaTypeDescriptor, true},
|
||||
{"invalid payload type", "application/vnd.test.fail", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equalf(t, tc.expected, attestation.ValidPayloadType(tc.payloadType), "expected %v for payload type %s", tc.expected, tc.payloadType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyUnsignedAttestation(t *testing.T) {
|
||||
ctx, _ := test.Setup(t)
|
||||
|
||||
payload := []byte("payload")
|
||||
env := &attestation.Envelope{
|
||||
// no signatures
|
||||
Signatures: []*attestation.Signature{},
|
||||
Payload: base64.StdEncoding.EncodeToString(payload),
|
||||
PayloadType: intoto.PayloadType,
|
||||
}
|
||||
opts := &attestation.VerifyOptions{
|
||||
Keys: attestation.Keys{},
|
||||
}
|
||||
|
||||
_, err := attestation.VerifyDSSE(ctx, env, opts)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no signatures")
|
||||
}
|
||||
51
attestation/vsa.go
Normal file
51
attestation/vsa.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package attestation
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
intoto "github.com/in-toto/in-toto-golang/in_toto"
|
||||
"github.com/package-url/packageurl-go"
|
||||
)
|
||||
|
||||
const (
|
||||
VSAPredicateType = "https://slsa.dev/verification_summary/v1"
|
||||
)
|
||||
|
||||
type VSAPredicate struct {
|
||||
Verifier VSAVerifier `json:"verifier"`
|
||||
TimeVerified string `json:"timeVerified"`
|
||||
ResourceURI string `json:"resourceUri"`
|
||||
Policy VSAPolicy `json:"policy"`
|
||||
InputAttestations []VSAInputAttestation `json:"inputAttestations,omitempty"`
|
||||
VerificationResult string `json:"verificationResult"`
|
||||
VerifiedLevels []string `json:"verifiedLevels"`
|
||||
}
|
||||
|
||||
type VSAVerifier struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type VSAPolicy struct {
|
||||
URI string `json:"uri,omitempty"`
|
||||
Digest map[string]string `json:"digest"`
|
||||
DownloadLocation string `json:"downloadLocation,omitempty"`
|
||||
}
|
||||
|
||||
type VSAInputAttestation struct {
|
||||
Digest map[string]string `json:"digest"`
|
||||
MediaType string `json:"mediaType"`
|
||||
}
|
||||
|
||||
func ToVSAResourceURI(sub intoto.Subject) (string, error) {
|
||||
// parse purl
|
||||
purl, err := packageurl.FromString(sub.Name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse package url: %w", err)
|
||||
}
|
||||
quals := purl.Qualifiers.Map()
|
||||
if quals["digest"] == "" {
|
||||
quals["digest"] = "sha256:" + sub.Digest["sha256"]
|
||||
}
|
||||
purl.Qualifiers = packageurl.QualifiersFromMap(quals)
|
||||
return purl.String(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user