diff --git a/internal/api/attestationconfigapi/cli/BUILD.bazel b/internal/api/attestationconfigapi/cli/BUILD.bazel index 03e6529d5f9..32ec8ec2bf2 100644 --- a/internal/api/attestationconfigapi/cli/BUILD.bazel +++ b/internal/api/attestationconfigapi/cli/BUILD.bazel @@ -22,7 +22,6 @@ go_library( "//internal/api/attestationconfigapi/cli/client", "//internal/api/fetcher", "//internal/attestation/variant", - "//internal/cloud/cloudprovider", "//internal/constants", "//internal/file", "//internal/logger", @@ -31,6 +30,7 @@ go_library( "@com_github_aws_aws_sdk_go_v2//aws", "@com_github_aws_aws_sdk_go_v2_service_s3//:s3", "@com_github_aws_aws_sdk_go_v2_service_s3//types", + "@com_github_google_go_tdx_guest//proto/tdx", "@com_github_spf13_afero//:afero", "@com_github_spf13_cobra//:cobra", ], diff --git a/internal/api/attestationconfigapi/cli/client/BUILD.bazel b/internal/api/attestationconfigapi/cli/client/BUILD.bazel index cacb2f05dca..b7e553f0598 100644 --- a/internal/api/attestationconfigapi/cli/client/BUILD.bazel +++ b/internal/api/attestationconfigapi/cli/client/BUILD.bazel @@ -5,7 +5,8 @@ go_library( name = "client", srcs = [ "client.go", - "reporter.go", + "reportersnp.go", + "reportertdx.go", ], importpath = "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi/cli/client", visibility = ["//:__subpackages__"], @@ -24,7 +25,7 @@ go_test( name = "client_test", srcs = [ "client_test.go", - "reporter_test.go", + "reportersnp_test.go", ], embed = [":client"], deps = [ diff --git a/internal/api/attestationconfigapi/cli/client/client.go b/internal/api/attestationconfigapi/cli/client/client.go index bf41b54763b..8882ff87b75 100644 --- a/internal/api/attestationconfigapi/cli/client/client.go +++ b/internal/api/attestationconfigapi/cli/client/client.go @@ -15,8 +15,12 @@ import ( "errors" "fmt" "log/slog" + "path" + "strings" "time" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go/aws" "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" apiclient "github.com/edgelesssys/constellation/v2/internal/api/client" "github.com/edgelesssys/constellation/v2/internal/attestation/variant" @@ -54,40 +58,23 @@ func New(ctx context.Context, cfg staticupload.Config, cosignPwd, privateKey []b return repo, clientClose, nil } -// uploadSEVSNPVersion uploads the latest version numbers of the SEVSNP. Then version name is the UTC timestamp of the date. The /list entry stores the version name + .json suffix. -func (a Client) uploadSEVSNPVersion(ctx context.Context, attestation variant.Variant, version attestationconfigapi.SEVSNPVersion, date time.Time) error { - versions, err := a.List(ctx, attestation) +// DeleteVersion deletes the given version (without .json suffix) from the API. +func (c Client) DeleteVersion(ctx context.Context, attestation variant.Variant, versionStr string) error { + versions, err := c.List(ctx, attestation) if err != nil { return fmt.Errorf("fetch version list: %w", err) } - ops := a.constructUploadCmd(attestation, version, versions, date) - return executeAllCmds(ctx, a.s3Client, ops) -} - -// DeleteSEVSNPVersion deletes the given version (without .json suffix) from the API. -func (a Client) DeleteSEVSNPVersion(ctx context.Context, attestation variant.Variant, versionStr string) error { - versions, err := a.List(ctx, attestation) - if err != nil { - return fmt.Errorf("fetch version list: %w", err) - } - - ops, err := a.deleteSEVSNPVersion(versions, versionStr) + ops, err := c.deleteVersion(versions, versionStr) if err != nil { return err } - return executeAllCmds(ctx, a.s3Client, ops) + return executeAllCmds(ctx, c.s3Client, ops) } // List returns the list of versions for the given attestation variant. -func (a Client) List(ctx context.Context, attestation variant.Variant) (attestationconfigapi.VersionList, error) { - if !attestation.Equal(variant.AzureSEVSNP{}) && - !attestation.Equal(variant.AWSSEVSNP{}) && - !attestation.Equal(variant.GCPSEVSNP{}) { - return attestationconfigapi.VersionList{}, fmt.Errorf("unsupported attestation variant: %s", attestation) - } - - versions, err := apiclient.Fetch(ctx, a.s3Client, attestationconfigapi.VersionList{Variant: attestation}) +func (c Client) List(ctx context.Context, attestation variant.Variant) (attestationconfigapi.VersionList, error) { + versions, err := apiclient.Fetch(ctx, c.s3Client, attestationconfigapi.VersionList{Variant: attestation}) if err != nil { var notFoundErr *apiclient.NotFoundError if errors.As(err, ¬FoundErr) { @@ -101,7 +88,7 @@ func (a Client) List(ctx context.Context, attestation variant.Variant) (attestat return versions, nil } -func (a Client) deleteSEVSNPVersion(versions attestationconfigapi.VersionList, versionStr string) (ops []crudCmd, err error) { +func (c Client) deleteVersion(versions attestationconfigapi.VersionList, versionStr string) (ops []crudCmd, err error) { versionStr = versionStr + ".json" ops = append(ops, deleteCmd{ apiObject: attestationconfigapi.VersionAPIEntry{ @@ -116,12 +103,12 @@ func (a Client) deleteSEVSNPVersion(versions attestationconfigapi.VersionList, v } ops = append(ops, putCmd{ apiObject: removedVersions, - signer: a.signer, + signer: c.signer, }) return ops, nil } -func (a Client) constructUploadCmd(attestation variant.Variant, version attestationconfigapi.SEVSNPVersion, versionNames attestationconfigapi.VersionList, date time.Time) []crudCmd { +func (c Client) constructUploadCmd(attestation variant.Variant, version attestationconfigapi.SEVSNPVersion, versionNames attestationconfigapi.VersionList, date time.Time) []crudCmd { if !attestation.Equal(versionNames.Variant) { return nil } @@ -131,19 +118,41 @@ func (a Client) constructUploadCmd(attestation variant.Variant, version attestat res = append(res, putCmd{ apiObject: attestationconfigapi.VersionAPIEntry{Version: dateStr, Variant: attestation, SEVSNPVersion: version}, - signer: a.signer, + signer: c.signer, }) versionNames.AddVersion(dateStr) res = append(res, putCmd{ apiObject: versionNames, - signer: a.signer, + signer: c.signer, }) return res } +func (c Client) listCachedVersions(ctx context.Context, attestation variant.Variant) ([]string, error) { + list, err := c.s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(c.bucketID), + Prefix: aws.String(reportVersionDir(attestation)), + }) + if err != nil { + return nil, fmt.Errorf("list objects: %w", err) + } + + var dates []string + for _, obj := range list.Contents { + fileName := path.Base(*obj.Key) + + // The cache contains signature and json files + // We only want the json files + if strings.HasSuffix(fileName, ".json") { + dates = append(dates, strings.TrimSuffix(fileName, ".json")) + } + } + return dates, nil +} + func removeVersion(list attestationconfigapi.VersionList, versionStr string) (removedVersions attestationconfigapi.VersionList, err error) { versions := list.List for i, v := range versions { diff --git a/internal/api/attestationconfigapi/cli/client/client_test.go b/internal/api/attestationconfigapi/cli/client/client_test.go index ff504bd00a9..81962abf8f2 100644 --- a/internal/api/attestationconfigapi/cli/client/client_test.go +++ b/internal/api/attestationconfigapi/cli/client/client_test.go @@ -44,7 +44,7 @@ func TestDeleteAzureSEVSNPVersions(t *testing.T) { } versions := attestationconfigapi.VersionList{List: []string{"2023-01-01.json", "2021-01-01.json", "2019-01-01.json"}} - ops, err := sut.deleteSEVSNPVersion(versions, "2021-01-01") + ops, err := sut.deleteVersion(versions, "2021-01-01") assert := assert.New(t) assert.NoError(err) diff --git a/internal/api/attestationconfigapi/cli/client/reporter.go b/internal/api/attestationconfigapi/cli/client/reporter.go deleted file mode 100644 index cc0f9b5eddd..00000000000 --- a/internal/api/attestationconfigapi/cli/client/reporter.go +++ /dev/null @@ -1,185 +0,0 @@ -/* -Copyright (c) Edgeless Systems GmbH - -SPDX-License-Identifier: AGPL-3.0-only -*/ - -package client - -import ( - "context" - "errors" - "fmt" - "path" - "sort" - "strings" - "time" - - "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/aws/aws-sdk-go/aws" - - "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" - "github.com/edgelesssys/constellation/v2/internal/api/client" - "github.com/edgelesssys/constellation/v2/internal/attestation/variant" -) - -// cachedVersionsSubDir is the subdirectory in the bucket where the cached versions are stored. -const cachedVersionsSubDir = "cached-versions" - -// ErrNoNewerVersion is returned if the input version is not newer than the latest API version. -var ErrNoNewerVersion = errors.New("input version is not newer than latest API version") - -func reportVersionDir(attestation variant.Variant) string { - return path.Join(attestationconfigapi.AttestationURLPath, attestation.String(), cachedVersionsSubDir) -} - -// UploadSEVSNPVersionLatest saves the given version to the cache, determines the smallest -// TCB version in the cache among the last cacheWindowSize versions and updates -// the latest version in the API if there is an update. -// force can be used to bypass the validation logic against the cached versions. -func (c Client) UploadSEVSNPVersionLatest(ctx context.Context, attestation variant.Variant, inputVersion, - latestAPIVersion attestationconfigapi.SEVSNPVersion, now time.Time, force bool, -) error { - if err := c.cacheSEVSNPVersion(ctx, attestation, inputVersion, now); err != nil { - return fmt.Errorf("reporting version: %w", err) - } - if force { - return c.uploadSEVSNPVersion(ctx, attestation, inputVersion, now) - } - versionDates, err := c.listCachedVersions(ctx, attestation) - if err != nil { - return fmt.Errorf("list reported versions: %w", err) - } - if len(versionDates) < c.cacheWindowSize { - c.s3Client.Logger.Warn(fmt.Sprintf("Skipping version update, found %d, expected %d reported versions.", len(versionDates), c.cacheWindowSize)) - return nil - } - minVersion, minDate, err := c.findMinVersion(ctx, attestation, versionDates) - if err != nil { - return fmt.Errorf("get minimal version: %w", err) - } - c.s3Client.Logger.Info(fmt.Sprintf("Found minimal version: %+v with date: %s", minVersion, minDate)) - shouldUpdateAPI, err := isInputNewerThanOtherVersion(minVersion, latestAPIVersion) - if err != nil { - return ErrNoNewerVersion - } - if !shouldUpdateAPI { - c.s3Client.Logger.Info(fmt.Sprintf("Input version: %+v is not newer than latest API version: %+v", minVersion, latestAPIVersion)) - return nil - } - c.s3Client.Logger.Info(fmt.Sprintf("Input version: %+v is newer than latest API version: %+v", minVersion, latestAPIVersion)) - t, err := time.Parse(VersionFormat, minDate) - if err != nil { - return fmt.Errorf("parsing date: %w", err) - } - if err := c.uploadSEVSNPVersion(ctx, attestation, minVersion, t); err != nil { - return fmt.Errorf("uploading version: %w", err) - } - c.s3Client.Logger.Info(fmt.Sprintf("Successfully uploaded new SEV-SNP version: %+v", minVersion)) - return nil -} - -// cacheSEVSNPVersion uploads the latest observed version numbers of the SEVSNP. This version is used to later report the latest version numbers to the API. -func (c Client) cacheSEVSNPVersion(ctx context.Context, attestation variant.Variant, version attestationconfigapi.SEVSNPVersion, date time.Time) error { - dateStr := date.Format(VersionFormat) + ".json" - res := putCmd{ - apiObject: reportedSEVSNPVersionAPI{Version: dateStr, variant: attestation, SEVSNPVersion: version}, - signer: c.signer, - } - return res.Execute(ctx, c.s3Client) -} - -func (c Client) listCachedVersions(ctx context.Context, attestation variant.Variant) ([]string, error) { - list, err := c.s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ - Bucket: aws.String(c.bucketID), - Prefix: aws.String(reportVersionDir(attestation)), - }) - if err != nil { - return nil, fmt.Errorf("list objects: %w", err) - } - var dates []string - for _, obj := range list.Contents { - fileName := path.Base(*obj.Key) - if strings.HasSuffix(fileName, ".json") { - dates = append(dates, fileName[:len(fileName)-5]) - } - } - return dates, nil -} - -// findMinVersion finds the minimal version of the given version dates among the latest values in the version window size. -func (c Client) findMinVersion(ctx context.Context, attesation variant.Variant, versionDates []string) (attestationconfigapi.SEVSNPVersion, string, error) { - var minimalVersion *attestationconfigapi.SEVSNPVersion - var minimalDate string - sort.Sort(sort.Reverse(sort.StringSlice(versionDates))) // sort in reverse order to slice the latest versions - versionDates = versionDates[:c.cacheWindowSize] - sort.Strings(versionDates) // sort with oldest first to to take the minimal version with the oldest date - for _, date := range versionDates { - obj, err := client.Fetch(ctx, c.s3Client, reportedSEVSNPVersionAPI{Version: date + ".json", variant: attesation}) - if err != nil { - return attestationconfigapi.SEVSNPVersion{}, "", fmt.Errorf("get object: %w", err) - } - // Need to set this explicitly as the variant is not part of the marshalled JSON. - obj.variant = attesation - - if minimalVersion == nil { - minimalVersion = &obj.SEVSNPVersion - minimalDate = date - } else { - shouldUpdateMinimal, err := isInputNewerThanOtherVersion(*minimalVersion, obj.SEVSNPVersion) - if err != nil { - continue - } - if shouldUpdateMinimal { - minimalVersion = &obj.SEVSNPVersion - minimalDate = date - } - } - } - return *minimalVersion, minimalDate, nil -} - -// isInputNewerThanOtherVersion compares all version fields and returns true if any input field is newer. -func isInputNewerThanOtherVersion(input, other attestationconfigapi.SEVSNPVersion) (bool, error) { - if input == other { - return false, nil - } - if input.TEE < other.TEE { - return false, fmt.Errorf("input TEE version: %d is older than latest API version: %d", input.TEE, other.TEE) - } - if input.SNP < other.SNP { - return false, fmt.Errorf("input SNP version: %d is older than latest API version: %d", input.SNP, other.SNP) - } - if input.Microcode < other.Microcode { - return false, fmt.Errorf("input Microcode version: %d is older than latest API version: %d", input.Microcode, other.Microcode) - } - if input.Bootloader < other.Bootloader { - return false, fmt.Errorf("input Bootloader version: %d is older than latest API version: %d", input.Bootloader, other.Bootloader) - } - return true, nil -} - -// reportedSEVSNPVersionAPI is the request to get the version information of the specific version in the config api. -type reportedSEVSNPVersionAPI struct { - Version string `json:"-"` - variant variant.Variant `json:"-"` - attestationconfigapi.SEVSNPVersion -} - -// JSONPath returns the path to the JSON file for the request to the config api. -func (i reportedSEVSNPVersionAPI) JSONPath() string { - return path.Join(reportVersionDir(i.variant), i.Version) -} - -// ValidateRequest validates the request. -func (i reportedSEVSNPVersionAPI) ValidateRequest() error { - if !strings.HasSuffix(i.Version, ".json") { - return fmt.Errorf("version has no .json suffix") - } - return nil -} - -// Validate is a No-Op at the moment. -func (i reportedSEVSNPVersionAPI) Validate() error { - return nil -} diff --git a/internal/api/attestationconfigapi/cli/client/reportersnp.go b/internal/api/attestationconfigapi/cli/client/reportersnp.go new file mode 100644 index 00000000000..3462558c0b1 --- /dev/null +++ b/internal/api/attestationconfigapi/cli/client/reportersnp.go @@ -0,0 +1,204 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package client + +import ( + "context" + "errors" + "fmt" + "path" + "sort" + "strings" + "time" + + "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" + "github.com/edgelesssys/constellation/v2/internal/api/client" + "github.com/edgelesssys/constellation/v2/internal/attestation/variant" +) + +// cachedVersionsSubDir is the subdirectory in the bucket where the cached versions are stored. +const cachedVersionsSubDir = "cached-versions" + +// ErrNoNewerVersion is returned if the input version is not newer than the latest API version. +var ErrNoNewerVersion = errors.New("input version is not newer than latest API version") + +func reportVersionDir(attestation variant.Variant) string { + return path.Join(attestationconfigapi.AttestationURLPath, attestation.String(), cachedVersionsSubDir) +} + +// UploadSEVSNPVersionLatest saves the given version to the cache, determines the smallest +// TCB version in the cache among the last cacheWindowSize versions and updates +// the latest version in the API if there is an update. +// force can be used to bypass the validation logic against the cached versions. +func (c Client) UploadSEVSNPVersionLatest( + ctx context.Context, attestationVariant variant.Variant, + inputVersion, latestVersionInAPI attestationconfigapi.SEVSNPVersion, + now time.Time, force bool, +) error { + // Start by uploading the new version to the cache. + if err := c.cacheSEVSNPVersion(ctx, attestationVariant, inputVersion, now); err != nil { + return fmt.Errorf("reporting version: %w", err) + } + + // If force is set, immediately update the latest version to the new version in the API. + if force { + return c.uploadSEVSNPVersion(ctx, attestationVariant, inputVersion, now) + } + + // Otherwise, check the cached versions and update the latest version in the API if necessary. + versionDates, err := c.listCachedVersions(ctx, attestationVariant) + if err != nil { + return fmt.Errorf("list reported versions: %w", err) + } + if len(versionDates) < c.cacheWindowSize { + c.s3Client.Logger.Warn(fmt.Sprintf("Skipping version update, found %d, expected %d reported versions.", len(versionDates), c.cacheWindowSize)) + return nil + } + + minVersion, minDate, err := c.findMinSEVSNPVersion(ctx, attestationVariant, versionDates) + if err != nil { + return fmt.Errorf("get minimal version: %w", err) + } + c.s3Client.Logger.Info(fmt.Sprintf("Found minimal version: %+v with date: %s", minVersion, minDate)) + + if isInputNewerThanOtherVersion(minVersion, latestVersionInAPI) { + c.s3Client.Logger.Info(fmt.Sprintf("Input version: %+v is not newer than latest API version: %+v", minVersion, latestVersionInAPI)) + return ErrNoNewerVersion + } + + c.s3Client.Logger.Info(fmt.Sprintf("Input version: %+v is newer than latest API version: %+v", minVersion, latestVersionInAPI)) + t, err := time.Parse(VersionFormat, minDate) + if err != nil { + return fmt.Errorf("parsing date: %w", err) + } + + if err := c.uploadSEVSNPVersion(ctx, attestationVariant, minVersion, t); err != nil { + return fmt.Errorf("uploading version: %w", err) + } + + c.s3Client.Logger.Info(fmt.Sprintf("Successfully uploaded new SEV-SNP version: %+v", minVersion)) + return nil +} + +// uploadSEVSNPVersion uploads the latest version numbers from a SEV-SNP report. +// The version name is the UTC timestamp of the date. +// The /list entry stores the version name + .json suffix. +func (c Client) uploadSEVSNPVersion(ctx context.Context, attestation variant.Variant, version attestationconfigapi.SEVSNPVersion, date time.Time) error { + versions, err := c.List(ctx, attestation) + if err != nil { + return fmt.Errorf("fetch version list: %w", err) + } + ops := c.constructUploadCmd(attestation, version, versions, date) + + return executeAllCmds(ctx, c.s3Client, ops) +} + +// cacheSEVSNPVersion uploads the latest observed version numbers of the SEVSNP. This version is used to later report the latest version numbers to the API. +func (c Client) cacheSEVSNPVersion(ctx context.Context, variant variant.Variant, version attestationconfigapi.SEVSNPVersion, date time.Time) error { + dateStr := date.Format(VersionFormat) + ".json" + res := putCmd{ + apiObject: cachedSEVSNPVersion{version: dateStr, variant: variant, SEVSNPVersion: version}, + signer: c.signer, + } + return res.Execute(ctx, c.s3Client) +} + +// findMinSEVSNPVersion finds the minimal version of the given version dates among the latest cached values in the version window size. +func (c Client) findMinSEVSNPVersion(ctx context.Context, attestationVariant variant.Variant, versionDates []string) (attestationconfigapi.SEVSNPVersion, string, error) { + var minimalVersion attestationconfigapi.SEVSNPVersion + var minimalDate string + sort.Sort(sort.Reverse(sort.StringSlice(versionDates))) // sort in reverse order to slice the latest versions + versionDates = versionDates[:c.cacheWindowSize] + sort.Strings(versionDates) // sort with oldest first to to take the minimal version with the oldest date + + for _, date := range versionDates { + obj, err := client.Fetch(ctx, c.s3Client, cachedSEVSNPVersion{version: date + ".json", variant: attestationVariant}) + if err != nil { + return attestationconfigapi.SEVSNPVersion{}, "", fmt.Errorf("get object: %w", err) + } + // Need to set this explicitly as the variant is not part of the marshalled JSON. + obj.variant = attestationVariant + + // If the version we fetched has higher SVNs than the current minimal version, update the minimal version. + if isInputNewerThanOtherVersion(obj.SEVSNPVersion, minimalVersion) { + minimalVersion = obj.SEVSNPVersion + minimalDate = date + } + } + + return minimalVersion, minimalDate, nil +} + +// isInputNewerThanOtherVersion compares all version fields and returns true if any input field is newer. +func isInputNewerThanOtherVersion(input, other attestationconfigapi.SEVSNPVersion) bool { + if input == other { + return false + } + if input.TEE < other.TEE { + return false + } + if input.SNP < other.SNP { + return false + } + if input.Microcode < other.Microcode { + return false + } + if input.Bootloader < other.Bootloader { + return false + } + return true +} + +type cachedSEVSNPVersion struct { + version string `json:"-"` + variant variant.Variant `json:"-"` + attestationconfigapi.SEVSNPVersion +} + +// JSONPath returns the path to the JSON file for the request to the config api. +// This is the path to the cached version in the S3 bucket. +func (c cachedSEVSNPVersion) JSONPath() string { + return path.Join(reportVersionDir(c.variant), c.version) +} + +// ValidateRequest validates the request. +func (c cachedSEVSNPVersion) ValidateRequest() error { + if !strings.HasSuffix(c.version, ".json") { + return fmt.Errorf("version has no .json suffix") + } + return nil +} + +// Validate is a No-Op at the moment. +func (c cachedSEVSNPVersion) Validate() error { + return nil +} + +type cachedTDXVersion struct { + version string `json:"-"` + variant variant.Variant `json:"-"` + attestationconfigapi.TDXVersion +} + +// JSONPath returns the path to the JSON file for the request to the config api. +// This is the path to the cached version in the S3 bucket. +func (c cachedTDXVersion) JSONPath() string { + return path.Join(reportVersionDir(c.variant), c.version) +} + +// ValidateRequest validates the request. +func (c cachedTDXVersion) ValidateRequest() error { + if !strings.HasSuffix(c.version, ".json") { + return fmt.Errorf("version has no .json suffix") + } + return nil +} + +// Validate is a No-Op at the moment. +func (c cachedTDXVersion) Validate() error { + return nil +} diff --git a/internal/api/attestationconfigapi/cli/client/reporter_test.go b/internal/api/attestationconfigapi/cli/client/reportersnp_test.go similarity index 78% rename from internal/api/attestationconfigapi/cli/client/reporter_test.go rename to internal/api/attestationconfigapi/cli/client/reportersnp_test.go index f3bc9a4fff6..ea80b098004 100644 --- a/internal/api/attestationconfigapi/cli/client/reporter_test.go +++ b/internal/api/attestationconfigapi/cli/client/reportersnp_test.go @@ -25,7 +25,6 @@ func TestIsInputNewerThanLatestAPI(t *testing.T) { latest attestationconfigapi.SEVSNPVersion input attestationconfigapi.SEVSNPVersion expect bool - errMsg string }{ "input is older than latest": { input: func(c attestationconfigapi.SEVSNPVersion) attestationconfigapi.SEVSNPVersion { @@ -34,7 +33,6 @@ func TestIsInputNewerThanLatestAPI(t *testing.T) { }(newTestCfg()), latest: newTestCfg(), expect: false, - errMsg: "input Microcode version: 92 is older than latest API version: 93", }, "input has greater and smaller version field than latest": { input: func(c attestationconfigapi.SEVSNPVersion) attestationconfigapi.SEVSNPVersion { @@ -44,7 +42,6 @@ func TestIsInputNewerThanLatestAPI(t *testing.T) { }(newTestCfg()), latest: newTestCfg(), expect: false, - errMsg: "input Bootloader version: 1 is older than latest API version: 2", }, "input is newer than latest": { input: func(c attestationconfigapi.SEVSNPVersion) attestationconfigapi.SEVSNPVersion { @@ -62,14 +59,8 @@ func TestIsInputNewerThanLatestAPI(t *testing.T) { } for name, tc := range testCases { t.Run(name, func(t *testing.T) { - isNewer, err := isInputNewerThanOtherVersion(tc.input, tc.latest) - assert := assert.New(t) - if tc.errMsg != "" { - assert.EqualError(err, tc.errMsg) - } else { - assert.NoError(err) - assert.Equal(tc.expect, isNewer) - } + isNewer := isInputNewerThanOtherVersion(tc.input, tc.latest) + assert.Equal(t, tc.expect, isNewer) }) } } diff --git a/internal/api/attestationconfigapi/cli/client/reportertdx.go b/internal/api/attestationconfigapi/cli/client/reportertdx.go new file mode 100644 index 00000000000..a36498f9e6f --- /dev/null +++ b/internal/api/attestationconfigapi/cli/client/reportertdx.go @@ -0,0 +1,143 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +package client + +import ( + "bytes" + "context" + "fmt" + "sort" + "time" + + "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" + "github.com/edgelesssys/constellation/v2/internal/api/client" + "github.com/edgelesssys/constellation/v2/internal/attestation/variant" +) + +// UploadTDXVersionLatest saves the given version to the cache, determines the smallest +// TCB version in the cache among the last cacheWindowSize versions and updates +// the latest version in the API if there is an update. +// force can be used to bypass the validation logic against the cached versions. +func (c Client) UploadTDXVersionLatest( + ctx context.Context, attestationVariant variant.Variant, + inputVersion, latestVersionInAPI attestationconfigapi.TDXVersion, + now time.Time, force bool, +) error { + // Start by uploading the new version to the cache. + if err := c.cacheTDXVersion(ctx, attestationVariant, inputVersion, now); err != nil { + return fmt.Errorf("reporting version: %w", err) + } + + // If force is set, immediately update the latest version to the new version in the API. + if force { + return c.uploadTDXVersion(ctx, attestationVariant, inputVersion, now) + } + + // Otherwise, check the cached versions and update the latest version in the API if necessary. + versionDates, err := c.listCachedVersions(ctx, attestationVariant) + if err != nil { + return fmt.Errorf("list reported versions: %w", err) + } + + if len(versionDates) < c.cacheWindowSize { + c.s3Client.Logger.Warn(fmt.Sprintf("Skipping version update, found %d, expected %d reported versions.", len(versionDates), c.cacheWindowSize)) + return nil + } + + minVersion, minDate, err := c.findMinTDXVersion(ctx, attestationVariant, versionDates) + if err != nil { + return fmt.Errorf("get minimal version: %w", err) + } + c.s3Client.Logger.Info(fmt.Sprintf("Found minimal version: %+v with date: %s", minVersion, minDate)) + + if !isInputNewerThanOtherTDXVersion(minVersion, latestVersionInAPI) { + c.s3Client.Logger.Info(fmt.Sprintf("Input version: %+v is not newer than latest API version: %+v", minVersion, latestVersionInAPI)) + return ErrNoNewerVersion + } + + c.s3Client.Logger.Info(fmt.Sprintf("Input version: %+v is newer than latest API version: %+v", minVersion, latestVersionInAPI)) + t, err := time.Parse(VersionFormat, minDate) + if err != nil { + return fmt.Errorf("parsing date: %w", err) + } + + if err := c.uploadTDXVersion(ctx, attestationVariant, minVersion, t); err != nil { + return fmt.Errorf("uploading version: %w", err) + } + + c.s3Client.Logger.Info(fmt.Sprintf("Successfully uploaded new TDX version: %+v", minVersion)) + return nil +} + +// uploadTDXVersion uploads the latest version numbers from a TDX report. +// The version name is the UTC timestamp of the date. +// The /list entry stores the version name + .json suffix. +func (c Client) uploadTDXVersion(ctx context.Context, attestation variant.Variant, version attestationconfigapi.TDXVersion, date time.Time) error { + dateStr := date.Format(VersionFormat) + ".json" + res := putCmd{ + apiObject: attestationconfigapi.VersionAPIEntry{Version: dateStr, Variant: attestation, TDXVersion: version}, + signer: c.signer, + } + return res.Execute(ctx, c.s3Client) +} + +func (c Client) cacheTDXVersion(ctx context.Context, variant variant.Variant, version attestationconfigapi.TDXVersion, date time.Time) error { + dateStr := date.Format(VersionFormat) + ".json" + res := putCmd{ + apiObject: cachedTDXVersion{version: dateStr, variant: variant, TDXVersion: version}, + signer: c.signer, + } + return res.Execute(ctx, c.s3Client) +} + +func (c Client) findMinTDXVersion(ctx context.Context, attestationVariant variant.Variant, versionDates []string) (attestationconfigapi.TDXVersion, string, error) { + var minimalVersion attestationconfigapi.TDXVersion + var minimalDate string + sort.Sort(sort.Reverse(sort.StringSlice(versionDates))) + versionDates = versionDates[:c.cacheWindowSize] + sort.Strings(versionDates) + + for _, date := range versionDates { + obj, err := client.Fetch(ctx, c.s3Client, cachedTDXVersion{version: date + ".json", variant: attestationVariant}) + if err != nil { + return attestationconfigapi.TDXVersion{}, "", fmt.Errorf("fetching version: %w", err) + } + obj.variant = attestationVariant + + // If the version we fetched has higher SVNs than the current minimal version, update the minimal version. + if isInputNewerThanOtherTDXVersion(obj.TDXVersion, minimalVersion) { + minimalVersion = obj.TDXVersion + minimalDate = date + } + } + + return minimalVersion, minimalDate, nil +} + +func isInputNewerThanOtherTDXVersion(input, other attestationconfigapi.TDXVersion) bool { + if input.PCESVN < other.PCESVN { + return false + } + if input.QESVN < other.QESVN { + return false + } + if bytes.Equal(input.QEVendorID[:], other.QEVendorID[:]) { + return false + } + if bytes.Equal(input.XFAM[:], other.XFAM[:]) { + return false + } + + // Validate component-wise security version numbers + for idx, inputVersion := range input.TEETCBSVN { + if inputVersion < other.TEETCBSVN[idx] { + return false + } + } + + return true +} diff --git a/internal/api/attestationconfigapi/cli/delete.go b/internal/api/attestationconfigapi/cli/delete.go index aa394733794..69dbeccb2c8 100644 --- a/internal/api/attestationconfigapi/cli/delete.go +++ b/internal/api/attestationconfigapi/cli/delete.go @@ -18,7 +18,6 @@ import ( "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi/cli/client" "github.com/edgelesssys/constellation/v2/internal/attestation/variant" - "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" "github.com/edgelesssys/constellation/v2/internal/logger" "github.com/edgelesssys/constellation/v2/internal/staticupload" "github.com/spf13/cobra" @@ -27,21 +26,21 @@ import ( // newDeleteCmd creates the delete command. func newDeleteCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "delete {aws|azure|gcp} {snp-report|guest-firmware} ", + Use: "delete {aws-sev-snp|azure-sev-snp|azure-tdx|gcp-sev-snp} {attestation-report|guest-firmware} ", Short: "Delete an object from the attestationconfig API", Long: "Delete a specific object version from the config api. is the name of the object to delete (without .json suffix)", Example: "COSIGN_PASSWORD=$CPW COSIGN_PRIVATE_KEY=$CKEY cli delete azure snp-report 1.0.0", - Args: cobra.MatchAll(cobra.ExactArgs(3), isCloudProvider(0), isValidKind(1)), + Args: cobra.MatchAll(cobra.ExactArgs(3), isAttestationVariant(0), isValidKind(1)), PreRunE: envCheck, RunE: runDelete, } recursivelyCmd := &cobra.Command{ - Use: "recursive {aws|azure|gcp}", + Use: "recursive {aws-sev-snp|azure-sev-snp|azure-tdx|gcp-sev-snp}", Short: "delete all objects from the API path constellation/v1/attestation/", Long: "Delete all objects from the API path constellation/v1/attestation/", Example: "COSIGN_PASSWORD=$CPW COSIGN_PRIVATE_KEY=$CKEY cli delete recursive azure", - Args: cobra.MatchAll(cobra.ExactArgs(1), isCloudProvider(0)), + Args: cobra.MatchAll(cobra.ExactArgs(1), isAttestationVariant(0)), RunE: runRecursiveDelete, } @@ -75,16 +74,7 @@ func runDelete(cmd *cobra.Command, args []string) (retErr error) { } }() - switch deleteCfg.provider { - case cloudprovider.AWS: - return deleteEntry(cmd.Context(), variant.AWSSEVSNP{}, client, deleteCfg) - case cloudprovider.Azure: - return deleteEntry(cmd.Context(), variant.AzureSEVSNP{}, client, deleteCfg) - case cloudprovider.GCP: - return deleteEntry(cmd.Context(), variant.GCPSEVSNP{}, client, deleteCfg) - default: - return fmt.Errorf("unsupported cloud provider: %s", deleteCfg.provider) - } + return deleteEntry(cmd.Context(), client, deleteCfg) } func runRecursiveDelete(cmd *cobra.Command, args []string) (retErr error) { @@ -112,23 +102,13 @@ func runRecursiveDelete(cmd *cobra.Command, args []string) (retErr error) { } }() - var deletePath string - switch deleteCfg.provider { - case cloudprovider.AWS: - deletePath = path.Join(attestationconfigapi.AttestationURLPath, variant.AWSSEVSNP{}.String()) - case cloudprovider.Azure: - deletePath = path.Join(attestationconfigapi.AttestationURLPath, variant.AzureSEVSNP{}.String()) - case cloudprovider.GCP: - deletePath = path.Join(attestationconfigapi.AttestationURLPath, variant.GCPSEVSNP{}.String()) - default: - return fmt.Errorf("unsupported cloud provider: %s", deleteCfg.provider) - } + deletePath := path.Join(attestationconfigapi.AttestationURLPath, deleteCfg.variant.String()) return deleteEntryRecursive(cmd.Context(), deletePath, client, deleteCfg) } type deleteConfig struct { - provider cloudprovider.Provider + variant variant.Variant kind objectKind version string region string @@ -155,12 +135,15 @@ func newDeleteConfig(cmd *cobra.Command, args [3]string) (deleteConfig, error) { } apiCfg := getAPIEnvironment(testing) - provider := cloudprovider.FromString(args[0]) + variant, err := variant.FromString(args[0]) + if err != nil { + return deleteConfig{}, fmt.Errorf("invalid attestation variant: %q: %w", args[0], err) + } kind := kindFromString(args[1]) version := args[2] return deleteConfig{ - provider: provider, + variant: variant, kind: kind, version: version, region: region, @@ -171,12 +154,12 @@ func newDeleteConfig(cmd *cobra.Command, args [3]string) (deleteConfig, error) { }, nil } -func deleteEntry(ctx context.Context, attvar variant.Variant, client *client.Client, cfg deleteConfig) error { - if cfg.kind != snpReport { +func deleteEntry(ctx context.Context, client *client.Client, cfg deleteConfig) error { + if cfg.kind != attestationReport { return fmt.Errorf("kind %s not supported", cfg.kind) } - return client.DeleteSEVSNPVersion(ctx, attvar, cfg.version) + return client.DeleteVersion(ctx, cfg.variant, cfg.version) } func deleteEntryRecursive(ctx context.Context, path string, client *staticupload.Client, cfg deleteConfig) error { diff --git a/internal/api/attestationconfigapi/cli/upload.go b/internal/api/attestationconfigapi/cli/upload.go index f0440033745..75b13b58572 100644 --- a/internal/api/attestationconfigapi/cli/upload.go +++ b/internal/api/attestationconfigapi/cli/upload.go @@ -7,6 +7,7 @@ package main import ( "context" + "encoding/binary" "errors" "fmt" "log/slog" @@ -17,18 +18,18 @@ import ( "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi/cli/client" "github.com/edgelesssys/constellation/v2/internal/api/fetcher" "github.com/edgelesssys/constellation/v2/internal/attestation/variant" - "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" "github.com/edgelesssys/constellation/v2/internal/file" "github.com/edgelesssys/constellation/v2/internal/logger" "github.com/edgelesssys/constellation/v2/internal/staticupload" "github.com/edgelesssys/constellation/v2/internal/verify" + "github.com/google/go-tdx-guest/proto/tdx" "github.com/spf13/afero" "github.com/spf13/cobra" ) func newUploadCmd() *cobra.Command { uploadCmd := &cobra.Command{ - Use: "upload {aws|azure|gcp} {snp-report|guest-firmware} ", + Use: "upload {aws-sev-snp|azure-sev-snp|azure-tdx|gcp-sev-snp} {attestation-report|guest-firmware} ", Short: "Upload an object to the attestationconfig API", Long: fmt.Sprintf("Upload a new object to the attestationconfig API. For snp-reports the new object is added to a cache folder first."+ @@ -40,7 +41,7 @@ func newUploadCmd() *cobra.Command { ), Example: "COSIGN_PASSWORD=$CPW COSIGN_PRIVATE_KEY=$CKEY cli upload azure snp-report /some/path/report.json", - Args: cobra.MatchAll(cobra.ExactArgs(3), isCloudProvider(0), isValidKind(1)), + Args: cobra.MatchAll(cobra.ExactArgs(3), isAttestationVariant(0), isValidKind(1)), PreRunE: envCheck, RunE: runUpload, } @@ -91,42 +92,19 @@ func runUpload(cmd *cobra.Command, args []string) (retErr error) { return fmt.Errorf("creating client: %w", err) } - var attestation variant.Variant - switch uploadCfg.provider { - case cloudprovider.AWS: - attestation = variant.AWSSEVSNP{} - case cloudprovider.Azure: - attestation = variant.AzureSEVSNP{} - case cloudprovider.GCP: - attestation = variant.GCPSEVSNP{} - default: - return fmt.Errorf("unsupported cloud provider: %s", uploadCfg.provider) - } - - return uploadReport(ctx, attestation, client, uploadCfg, file.NewHandler(afero.NewOsFs()), log) + return uploadReport(ctx, client, uploadCfg, file.NewHandler(afero.NewOsFs()), log) } -func uploadReport(ctx context.Context, - attestation variant.Variant, - apiClient *client.Client, - cfg uploadConfig, - fs file.Handler, - log *slog.Logger, +func uploadReport( + ctx context.Context, apiClient *client.Client, + cfg uploadConfig, fs file.Handler, log *slog.Logger, ) error { - if cfg.kind != snpReport { + if cfg.kind != attestationReport { return fmt.Errorf("kind %s not supported", cfg.kind) } - log.Info(fmt.Sprintf("Reading SNP report from file: %s", cfg.path)) - var report verify.Report - if err := fs.ReadJSON(cfg.path, &report); err != nil { - return fmt.Errorf("reading snp report: %w", err) - } - - inputVersion := convertTCBVersionToSNPVersion(report.SNPReport.LaunchTCB) - log.Info(fmt.Sprintf("Input report: %+v", inputVersion)) - - latestAPIVersionAPI, err := attestationconfigapi.NewFetcherWithCustomCDNAndCosignKey(cfg.url, cfg.cosignPublicKey).FetchLatestVersion(ctx, attestation) + apiFetcher := attestationconfigapi.NewFetcherWithCustomCDNAndCosignKey(cfg.url, cfg.cosignPublicKey) + latestVersionInAPI, err := apiFetcher.FetchLatestVersion(ctx, cfg.variant) if err != nil { var notFoundErr *fetcher.NotFoundError if errors.As(err, ¬FoundErr) { @@ -136,13 +114,42 @@ func uploadReport(ctx context.Context, } } - latestAPIVersion := latestAPIVersionAPI.SEVSNPVersion - if err := apiClient.UploadSEVSNPVersionLatest(ctx, attestation, inputVersion, latestAPIVersion, cfg.uploadDate, cfg.force); err != nil { - if errors.Is(err, client.ErrNoNewerVersion) { - log.Info(fmt.Sprintf("Input version: %+v is not newer than latest API version: %+v", inputVersion, latestAPIVersion)) - return nil + switch cfg.variant { + case variant.AWSSEVSNP{}, variant.AzureSEVSNP{}, variant.GCPSEVSNP{}: + log.Info(fmt.Sprintf("Reading SNP report from file: %s", cfg.path)) + var report verify.Report + if err := fs.ReadJSON(cfg.path, &report); err != nil { + return fmt.Errorf("reading snp report: %w", err) } - return fmt.Errorf("updating latest version: %w", err) + + newVersion := convertTCBVersionToSNPVersion(report.SNPReport.LaunchTCB) + log.Info(fmt.Sprintf("Input SNP report: %+v", newVersion)) + + if err := apiClient.UploadSEVSNPVersionLatest(ctx, cfg.variant, newVersion, latestVersionInAPI.SEVSNPVersion, cfg.uploadDate, cfg.force); err != nil { + if errors.Is(err, client.ErrNoNewerVersion) { + log.Info(fmt.Sprintf("Input version: %+v is not newer than latest API version: %+v", newVersion, latestVersionInAPI)) + } + return fmt.Errorf("updating latest version: %w", err) + } + case variant.AzureTDX{}: + log.Info(fmt.Sprintf("Reading TDX report from file: %s", cfg.path)) + var report *tdx.QuoteV4 + if err := fs.ReadJSON(cfg.path, &report); err != nil { + return fmt.Errorf("reading tdx report: %w", err) + } + + newVersion := convertQuoteToTDXVersion(report) + log.Info(fmt.Sprintf("Input TDX report: %+v", newVersion)) + + if err := apiClient.UploadTDXVersionLatest(ctx, cfg.variant, newVersion, latestVersionInAPI.TDXVersion, cfg.uploadDate, cfg.force); err != nil { + if errors.Is(err, client.ErrNoNewerVersion) { + log.Info(fmt.Sprintf("Input version: %+v is not newer than latest API version: %+v", newVersion, latestVersionInAPI)) + } + return fmt.Errorf("updating latest version: %w", err) + } + + default: + return fmt.Errorf("variant %s not supported", cfg.variant) } return nil @@ -157,8 +164,18 @@ func convertTCBVersionToSNPVersion(tcb verify.TCBVersion) attestationconfigapi.S } } +func convertQuoteToTDXVersion(quote *tdx.QuoteV4) attestationconfigapi.TDXVersion { + return attestationconfigapi.TDXVersion{ + QESVN: binary.LittleEndian.Uint16(quote.Header.QeSvn), + PCESVN: binary.LittleEndian.Uint16(quote.Header.PceSvn), + QEVendorID: [16]byte(quote.Header.QeVendorId), + XFAM: [8]byte(quote.TdQuoteBody.Xfam), + TEETCBSVN: [16]byte(quote.TdQuoteBody.TeeTcbSvn), + } +} + type uploadConfig struct { - provider cloudprovider.Provider + variant variant.Variant kind objectKind path string uploadDate time.Time @@ -210,12 +227,16 @@ func newConfig(cmd *cobra.Command, args [3]string) (uploadConfig, error) { return uploadConfig{}, fmt.Errorf("getting cache window size: %w", err) } - provider := cloudprovider.FromString(args[0]) + variant, err := variant.FromString(args[0]) + if err != nil { + return uploadConfig{}, fmt.Errorf("invalid attestation variant: %q: %w", args[0], err) + } + kind := kindFromString(args[1]) path := args[2] return uploadConfig{ - provider: provider, + variant: variant, kind: kind, path: path, uploadDate: uploadDate, diff --git a/internal/api/attestationconfigapi/cli/validargs.go b/internal/api/attestationconfigapi/cli/validargs.go index 22a11ac2181..0c77ce0512d 100644 --- a/internal/api/attestationconfigapi/cli/validargs.go +++ b/internal/api/attestationconfigapi/cli/validargs.go @@ -10,16 +10,22 @@ import ( "fmt" "strings" - "github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider" + "github.com/edgelesssys/constellation/v2/internal/attestation/variant" "github.com/spf13/cobra" ) -func isCloudProvider(arg int) cobra.PositionalArgs { +func isAttestationVariant(arg int) cobra.PositionalArgs { return func(_ *cobra.Command, args []string) error { - if provider := cloudprovider.FromString(args[arg]); provider == cloudprovider.Unknown { - return fmt.Errorf("argument %s isn't a valid cloud provider", args[arg]) + attestationVariant, err := variant.FromString(args[arg]) + if err != nil { + return fmt.Errorf("argument %s isn't a valid attestation variant", args[arg]) + } + switch attestationVariant { + case variant.AWSSEVSNP{}, variant.AzureSEVSNP{}, variant.AzureTDX{}, variant.GCPSEVSNP{}: + return nil + default: + return fmt.Errorf("argument %s isn't a supported attestation variant", args[arg]) } - return nil } } @@ -37,16 +43,15 @@ type objectKind string const ( // unknown is the default objectKind and does nothing. - unknown objectKind = "unknown-kind" - snpReport objectKind = "snp-report" - tdxReport objectKind = "tdx-report" - guestFirmware objectKind = "guest-firmware" + unknown objectKind = "unknown-kind" + attestationReport objectKind = "attestation-report" + guestFirmware objectKind = "guest-firmware" ) func kindFromString(s string) objectKind { lower := strings.ToLower(s) switch objectKind(lower) { - case snpReport, guestFirmware, tdxReport: + case attestationReport, guestFirmware: return objectKind(lower) default: return unknown