diff --git a/go.mod b/go.mod index 6a4950a..4667eb5 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/docker/attest go 1.22.1 require ( + github.com/Masterminds/semver/v3 v3.2.1 github.com/aws/aws-sdk-go-v2/config v1.27.15 github.com/containerd/containerd v1.7.17 github.com/distribution/reference v0.6.0 diff --git a/go.sum b/go.sum index ef2afd8..fcf4272 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUM github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2 h1:XHOnouVk1mxXfQidrMEnLlPk9UMeRtyBTnEFtxkV0kU= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.12.3 h1:LS9NXqXhMoqNCplK1ApmVSfB4UnVLRDWRapB6EIlxE0= diff --git a/pkg/attest/example_verify_test.go b/pkg/attest/example_verify_test.go index 8e46e50..a50fabd 100644 --- a/pkg/attest/example_verify_test.go +++ b/pkg/attest/example_verify_test.go @@ -21,7 +21,7 @@ func createTufClient(outputPath string) (*tuf.TufClient, error) { // metadataURI := "https://docker.github.io/tuf-staging/metadata" // targetsURI := "https://docker.github.io/tuf-staging/targets" - return tuf.NewTufClient(embed.StagingRoot, outputPath, metadataURI, targetsURI) + return tuf.NewTufClient(embed.StagingRoot, outputPath, metadataURI, targetsURI, tuf.NewMockVersionChecker()) } func ExampleVerify_remote() { diff --git a/pkg/mirror/example_mirror_test.go b/pkg/mirror/example_mirror_test.go index ea2bd2a..c4b6cbd 100644 --- a/pkg/mirror/example_mirror_test.go +++ b/pkg/mirror/example_mirror_test.go @@ -8,6 +8,7 @@ import ( "github.com/docker/attest/internal/embed" "github.com/docker/attest/pkg/mirror" + "github.com/docker/attest/pkg/tuf" v1 "github.com/google/go-containerregistry/pkg/v1" ) @@ -28,7 +29,7 @@ func ExampleNewTufMirror() { // configure TUF mirror metadataURI := "https://docker.github.io/tuf-staging/metadata" targetsURI := "https://docker.github.io/tuf-staging/targets" - m, err := mirror.NewTufMirror(embed.StagingRoot, tufOutputPath, metadataURI, targetsURI) + m, err := mirror.NewTufMirror(embed.StagingRoot, tufOutputPath, metadataURI, targetsURI, tuf.NewMockVersionChecker()) if err != nil { panic(err) } diff --git a/pkg/mirror/metadata_test.go b/pkg/mirror/metadata_test.go index a34e8d7..0aeb277 100644 --- a/pkg/mirror/metadata_test.go +++ b/pkg/mirror/metadata_test.go @@ -11,6 +11,7 @@ import ( "github.com/docker/attest/internal/embed" "github.com/docker/attest/internal/test" + "github.com/docker/attest/pkg/tuf" "github.com/stretchr/testify/assert" "github.com/theupdateframework/go-tuf/v2/metadata" ) @@ -20,7 +21,7 @@ func TestGetTufMetadataMirror(t *testing.T) { defer server.Close() path := test.CreateTempDir(t, "", "tuf_temp") - m, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets") + m, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker()) assert.NoError(t, err) tufMetadata, err := m.getTufMetadataMirror(server.URL + "/metadata") @@ -38,7 +39,7 @@ func TestGetMetadataManifest(t *testing.T) { defer server.Close() path := test.CreateTempDir(t, "", "tuf_temp") - m, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets") + m, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker()) assert.NoError(t, err) img, err := m.GetMetadataManifest(server.URL + "/metadata") @@ -78,7 +79,7 @@ func TestGetDelegatedMetadataMirrors(t *testing.T) { defer server.Close() path := test.CreateTempDir(t, "", "tuf_temp") - m, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets") + m, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker()) assert.NoError(t, err) delegations, err := m.GetDelegatedMetadataMirrors() diff --git a/pkg/mirror/mirror.go b/pkg/mirror/mirror.go index 3abf107..5aa5171 100644 --- a/pkg/mirror/mirror.go +++ b/pkg/mirror/mirror.go @@ -15,11 +15,11 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" ) -func NewTufMirror(root []byte, tufPath, metadataURL, targetsURL string) (*TufMirror, error) { +func NewTufMirror(root []byte, tufPath, metadataURL, targetsURL string, versionChecker tuf.VersionChecker) (*TufMirror, error) { if root == nil { root = embed.DefaultRoot } - tufClient, err := tuf.NewTufClient(root, tufPath, metadataURL, targetsURL) + tufClient, err := tuf.NewTufClient(root, tufPath, metadataURL, targetsURL, versionChecker) if err != nil { return nil, fmt.Errorf("failed to create TUF client: %w", err) } diff --git a/pkg/mirror/targets_test.go b/pkg/mirror/targets_test.go index 3b544da..e81ddaf 100644 --- a/pkg/mirror/targets_test.go +++ b/pkg/mirror/targets_test.go @@ -10,6 +10,7 @@ import ( "github.com/docker/attest/internal/embed" "github.com/docker/attest/internal/test" + "github.com/docker/attest/pkg/tuf" "github.com/stretchr/testify/assert" ) @@ -26,7 +27,7 @@ func TestGetTufTargetsMirror(t *testing.T) { defer server.Close() path := test.CreateTempDir(t, "", "tuf_temp") - m, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets") + m, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker()) assert.NoError(t, err) targets, err := m.GetTufTargetMirrors() @@ -60,7 +61,7 @@ func TestTargetDelegationMetadata(t *testing.T) { defer server.Close() path := test.CreateTempDir(t, "", "tuf_temp") - tm, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets") + tm, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker()) assert.NoError(t, err) targets, err := tm.TufClient.LoadDelegatedTargets("test-role", "targets") @@ -73,7 +74,7 @@ func TestGetDelegatedTargetMirrors(t *testing.T) { defer server.Close() path := test.CreateTempDir(t, "", "tuf_temp") - m, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets") + m, err := NewTufMirror(embed.DevRoot, path, server.URL+"/metadata", server.URL+"/targets", tuf.NewMockVersionChecker()) assert.NoError(t, err) mirrors, err := m.GetDelegatedTargetMirrors() diff --git a/pkg/tuf/example_registry_test.go b/pkg/tuf/example_registry_test.go index 0df14fd..df34c83 100644 --- a/pkg/tuf/example_registry_test.go +++ b/pkg/tuf/example_registry_test.go @@ -20,7 +20,8 @@ func ExampleNewTufClient_registry() { // using oci tuf metadata and targets metadataURI := "registry-1.docker.io/docker/tuf-metadata:latest" targetsURI := "registry-1.docker.io/docker/tuf-targets" - registryClient, err := tuf.NewTufClient(embed.StagingRoot, tufOutputPath, metadataURI, targetsURI) + + registryClient, err := tuf.NewTufClient(embed.StagingRoot, tufOutputPath, metadataURI, targetsURI, tuf.NewMockVersionChecker()) if err != nil { panic(err) } diff --git a/pkg/tuf/mock.go b/pkg/tuf/mock.go index aa9c433..95afba8 100644 --- a/pkg/tuf/mock.go +++ b/pkg/tuf/mock.go @@ -58,3 +58,15 @@ func (dc *mockTufClient) DownloadTarget(target string, filePath string) (actualF return dstFilePath, b, nil } + +type mockVersionChecker struct { + err error +} + +func NewMockVersionChecker() *mockVersionChecker { + return &mockVersionChecker{} +} + +func (vc *mockVersionChecker) CheckVersion(client TUFClient) error { + return vc.err +} diff --git a/pkg/tuf/tuf.go b/pkg/tuf/tuf.go index 48a1e13..07d9742 100644 --- a/pkg/tuf/tuf.go +++ b/pkg/tuf/tuf.go @@ -36,7 +36,7 @@ type TufClient struct { } // NewTufClient creates a new TUF client -func NewTufClient(initialRoot []byte, tufPath, metadataSource, targetsSource string) (*TufClient, error) { +func NewTufClient(initialRoot []byte, tufPath, metadataSource, targetsSource string, versionChecker VersionChecker) (*TufClient, error) { var tufSource TufSource if strings.HasPrefix(metadataSource, "https://") || strings.HasPrefix(metadataSource, "http://") { tufSource = HttpSource @@ -102,8 +102,13 @@ func NewTufClient(initialRoot []byte, tufPath, metadataSource, targetsSource str updater: up, cfg: cfg, } - return client, nil + err = versionChecker.CheckVersion(client) + if err != nil { + return nil, err + } + + return client, nil } // DownloadTarget downloads the target file using Updater. The Updater gets the target diff --git a/pkg/tuf/tuf_test.go b/pkg/tuf/tuf_test.go index 9479dcf..ad57cc8 100644 --- a/pkg/tuf/tuf_test.go +++ b/pkg/tuf/tuf_test.go @@ -52,6 +52,9 @@ func TestRootInit(t *testing.T) { }() LoadRegistryTestData(t, regAddr, OciTufTestDataPath) + alwaysGoodVersionChecker := &mockVersionChecker{err: nil} + alwaysBadVersionChecker := &mockVersionChecker{err: assert.AnError} + testCases := []struct { name string metadataSource string @@ -62,15 +65,18 @@ func TestRootInit(t *testing.T) { } for _, tc := range testCases { - _, err := NewTufClient(embed.DevRoot, tufPath, tc.metadataSource, tc.targetsSource) + _, err := NewTufClient(embed.DevRoot, tufPath, tc.metadataSource, tc.targetsSource, alwaysGoodVersionChecker) assert.NoErrorf(t, err, "Failed to create TUF client: %v", err) // recreation should work with same root - _, err = NewTufClient(embed.DevRoot, tufPath, tc.metadataSource, tc.targetsSource) + _, err = NewTufClient(embed.DevRoot, tufPath, tc.metadataSource, tc.targetsSource, alwaysGoodVersionChecker) assert.NoErrorf(t, err, "Failed to recreate TUF client: %v", err) - _, err = NewTufClient([]byte("broken"), tufPath, tc.metadataSource, tc.targetsSource) + _, err = NewTufClient([]byte("broken"), tufPath, tc.metadataSource, tc.targetsSource, alwaysGoodVersionChecker) assert.Errorf(t, err, "Expected error recreating TUF client with broken root: %v", err) + + _, err = NewTufClient(embed.DevRoot, tufPath, tc.metadataSource, tc.targetsSource, alwaysBadVersionChecker) + assert.Errorf(t, err, "Expected error creating TUF client with bad attest version: %v", err) } } @@ -93,6 +99,8 @@ func TestDownloadTarget(t *testing.T) { }() LoadRegistryTestData(t, regAddr, OciTufTestDataPath) + alwaysGoodVersionChecker := &mockVersionChecker{err: nil} + testCases := []struct { name string metadataSource string @@ -103,7 +111,7 @@ func TestDownloadTarget(t *testing.T) { } for _, tc := range testCases { - tufClient, err := NewTufClient(embed.DevRoot, tufPath, tc.metadataSource, tc.targetsSource) + tufClient, err := NewTufClient(embed.DevRoot, tufPath, tc.metadataSource, tc.targetsSource, alwaysGoodVersionChecker) assert.NoErrorf(t, err, "Failed to create TUF client: %v", err) // get trusted tuf metadata diff --git a/pkg/tuf/version.go b/pkg/tuf/version.go new file mode 100644 index 0000000..a9eef52 --- /dev/null +++ b/pkg/tuf/version.go @@ -0,0 +1,89 @@ +package tuf + +import ( + "fmt" + "runtime/debug" + "strings" + + "github.com/Masterminds/semver/v3" +) + +const ThisModulePath = "github.com/docker/attest" + +type VersionChecker interface { + // CheckVersion checks if the current version of this library meets the constraints from the TUF repo + CheckVersion(tufClient TUFClient) error +} + +type InvalidVersionError struct { + AttestVersion string + VersionConstraint string + Errors []error +} + +func (e *InvalidVersionError) Error() string { + var errsStr strings.Builder + for i, err := range e.Errors { + if i > 0 { + errsStr.WriteString("; ") + } + errsStr.WriteString(err.Error()) + } + return fmt.Sprintf("%s version %s does not satisfy constraints %s: %s", ThisModulePath, e.AttestVersion, e.VersionConstraint, errsStr.String()) +} + +func NewVersionChecker() *versionChecker { + return &versionChecker{} +} + +type versionChecker struct{} + +func (vc *versionChecker) CheckVersion(client TUFClient) error { + var attestMod *debug.Module + bi, ok := debug.ReadBuildInfo() + if !ok { + // if we can't read the build info, assume we're good. this should only happen if we're not running in a module + return nil + } + if bi.Main.Path == ThisModulePath { + attestMod = &bi.Main + } else { + for _, dep := range bi.Deps { + if dep.Path == ThisModulePath { + attestMod = dep + break + } + } + } + if attestMod == nil { + // if we can't find the attest dep, assume we're good. this should only happen in a test + return nil + } + + attestVersion, err := semver.NewVersion(attestMod.Version) + if err != nil { + return fmt.Errorf("failed to parse version %s: %w", attestMod.Version, err) + } + + // see https://github.com/Masterminds/semver/blob/v3.2.1/README.md#checking-version-constraints + // for more information on the expected format of the version constraints in the TUF repo + _, versionConstraintsBytes, err := client.DownloadTarget("version-constraints", "") + if err != nil { + return fmt.Errorf("failed to download version-constraints: %w", err) + } + versionConstraints, err := semver.NewConstraint(string(versionConstraintsBytes)) + if err != nil { + return fmt.Errorf("failed to parse minimum version: %w", err) + } + + ok, errs := versionConstraints.Validate(attestVersion) + if !ok { + return &InvalidVersionError{ + AttestVersion: attestVersion.String(), + VersionConstraint: versionConstraints.String(), + Errors: errs, + } + } + + return nil +}