diff --git a/go.mod b/go.mod index c7a9983fc58..39927a3fdc7 100644 --- a/go.mod +++ b/go.mod @@ -53,6 +53,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/network/armnetwork/v4 v4.0.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.0.0 github.com/Azure/go-autorest/autorest/to v0.4.0 + github.com/aws/aws-sdk-go v1.44.297 github.com/aws/aws-sdk-go-v2 v1.18.1 github.com/aws/aws-sdk-go-v2/config v1.18.27 github.com/aws/aws-sdk-go-v2/credentials v1.13.26 @@ -162,7 +163,6 @@ require ( github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go v1.44.297 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.34 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.28 // indirect diff --git a/hack/go.mod b/hack/go.mod index 6c022666edf..ce4e711c4ec 100644 --- a/hack/go.mod +++ b/hack/go.mod @@ -88,6 +88,7 @@ require ( github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go v1.44.297 // indirect github.com/aws/aws-sdk-go-v2 v1.18.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.13.26 // indirect diff --git a/hack/go.sum b/hack/go.sum index 4ac1bb8e23c..414df2eb430 100644 --- a/hack/go.sum +++ b/hack/go.sum @@ -138,6 +138,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/asaskevich/govalidator v0.0.0-20200907205600-7a23bdc65eef/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go v1.44.297 h1:uL4EV0gQxotQVYegIoBqK079328MOJqgG95daFYSkAM= +github.com/aws/aws-sdk-go v1.44.297/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go-v2 v1.18.1 h1:+tefE750oAb7ZQGzla6bLkOwfcQCEtC5y2RqoqCeqKo= github.com/aws/aws-sdk-go-v2 v1.18.1/go.mod h1:uzbQtefpm44goOPmdKyAlXSNcwlRgF3ePWVW6EtJvvw= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.4.10 h1:dK82zF6kkPeCo8J1e+tGx4JdvDIQzj7ygIoLg8WMuGs= @@ -1158,6 +1160,7 @@ golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1 golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= @@ -1264,6 +1267,7 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221013171732-95e765b1cc43/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -1273,6 +1277,7 @@ golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/internal/api/attestationconfigapi/BUILD.bazel b/internal/api/attestationconfigapi/BUILD.bazel index 145ff64615f..9959b8b3bfa 100644 --- a/internal/api/attestationconfigapi/BUILD.bazel +++ b/internal/api/attestationconfigapi/BUILD.bazel @@ -8,6 +8,7 @@ go_library( "azure.go", "client.go", "fetcher.go", + "reporter.go", ], importpath = "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi", visibility = ["//:__subpackages__"], @@ -19,6 +20,8 @@ go_library( "//internal/logger", "//internal/sigstore", "//internal/staticupload", + "@com_github_aws_aws_sdk_go//aws", + "@com_github_aws_aws_sdk_go_v2_service_s3//:s3", ], ) @@ -27,6 +30,7 @@ go_test( srcs = [ "client_test.go", "fetcher_test.go", + "reporter_test.go", ], embed = [":attestationconfigapi"], deps = ["@com_github_stretchr_testify//assert"], diff --git a/internal/api/attestationconfigapi/cli/main.go b/internal/api/attestationconfigapi/cli/main.go index af4cd2e3cf6..6bb96e8553b 100644 --- a/internal/api/attestationconfigapi/cli/main.go +++ b/internal/api/attestationconfigapi/cli/main.go @@ -8,8 +8,10 @@ SPDX-License-Identifier: AGPL-3.0-only This package provides a CLI to interact with the Attestationconfig API, a sub API of the Resource API. You can execute an e2e test by running: `bazel run //internal/api/attestationconfigapi:configapi_e2e_test`. -The CLI is used in the CI pipeline. Actions that change the bucket's data shouldn't be executed manually. -Notice that there is no synchronization on API operations. +The CLI is used in the CI pipeline. Manual actions that change the bucket's data shouldn't be necessary. +The reporter CLI caches the observed version values in a dedicated caching directory and derives the latest API version from it. +Any version update is then pushed to the API. +Notice that there is no synchronization on API operations. // TODO(elchead): what does this mean? */ package main @@ -56,7 +58,7 @@ func newRootCmd() *cobra.Command { Use: "COSIGN_PASSWORD=$CPWD COSIGN_PRIVATE_KEY=$CKEY upload --version-file $FILE", Short: "Upload a set of versions specific to the azure-sev-snp attestation variant to the config api.", - Long: fmt.Sprintf("Upload a set of versions specific to the azure-sev-snp attestation variant to the config api."+ + Long: fmt.Sprintf("The reporter uploads an observed version number specific to the azure-sev-snp attestation variant to the reporter cache. The reporter then deduces a latest version value from the cache and updates the config api if necessary. "+ "Please authenticate with AWS through your preferred method (e.g. environment variables, CLI)"+ "to be able to upload to S3. Set the %s and %s environment variables to authenticate with cosign.", envCosignPrivateKey, envCosignPwd, @@ -116,16 +118,6 @@ func runCmd(cmd *cobra.Command, _ []string) (retErr error) { } latestAPIVersion := latestAPIVersionAPI.AzureSEVSNPVersion - isNewer, err := isInputNewerThanLatestAPI(inputVersion, latestAPIVersion) - if err != nil { - return fmt.Errorf("comparing versions: %w", err) - } - if !isNewer { - log.Infof("Input version: %+v is not newer than latest API version: %+v", inputVersion, latestAPIVersion) - return nil - } - log.Infof("Input version: %+v is newer than latest API version: %+v", inputVersion, latestAPIVersion) - client, clientClose, err := attestationconfigapi.NewClient(ctx, cfg, []byte(cosignPwd), []byte(privateKey), false, log) defer func() { err := clientClose(cmd.Context()) @@ -137,9 +129,12 @@ func runCmd(cmd *cobra.Command, _ []string) (retErr error) { if err != nil { return fmt.Errorf("creating client: %w", err) } - - if err := client.UploadAzureSEVSNPVersion(ctx, inputVersion, flags.uploadDate); err != nil { - return fmt.Errorf("uploading version: %w", err) + reporter := attestationconfigapi.Reporter{Client: client} + if err := reporter.ReportAzureSEVSNPVersion(ctx, inputVersion, flags.uploadDate); err != nil { + return fmt.Errorf("reporting version: %w", err) + } + if err := reporter.UpdateLatestVersion(ctx, latestAPIVersion); err != nil { + return fmt.Errorf("updating latest version: %w", err) } cmd.Printf("Successfully uploaded new Azure SEV-SNP version: %+v\n", inputVersion) @@ -215,26 +210,6 @@ func (c maaTokenTCBClaims) ToAzureSEVSNPVersion() attestationconfigapi.AzureSEVS } } -// isInputNewerThanLatestAPI compares all version fields with the latest API version and returns true if any input field is newer. -func isInputNewerThanLatestAPI(input, latest attestationconfigapi.AzureSEVSNPVersion) (bool, error) { - if input == latest { - return false, nil - } - if input.TEE < latest.TEE { - return false, fmt.Errorf("input TEE version: %d is older than latest API version: %d", input.TEE, latest.TEE) - } - if input.SNP < latest.SNP { - return false, fmt.Errorf("input SNP version: %d is older than latest API version: %d", input.SNP, latest.SNP) - } - if input.Microcode < latest.Microcode { - return false, fmt.Errorf("input Microcode version: %d is older than latest API version: %d", input.Microcode, latest.Microcode) - } - if input.Bootloader < latest.Bootloader { - return false, fmt.Errorf("input Bootloader version: %d is older than latest API version: %d", input.Bootloader, latest.Bootloader) - } - return true, nil -} - func must(err error) { if err != nil { panic(err) diff --git a/internal/api/attestationconfigapi/reporter.go b/internal/api/attestationconfigapi/reporter.go new file mode 100644 index 00000000000..86aaa49f3a9 --- /dev/null +++ b/internal/api/attestationconfigapi/reporter.go @@ -0,0 +1,200 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ +package attestationconfigapi + +import ( + "context" + "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/client" + apiclient "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" + +// timeFrameForCachedVersions defines the time frame for reported versions which are considered to define the latest version. +const timeFrameForCachedVersions = 21 * 24 * time.Hour + +var reportVersionDir = path.Join(attestationURLPath, variant.AzureSEVSNP{}.String(), cachedVersionsSubDir) + +// Reporter caches observed version numbers and reports the latest version numbers to the API. +type Reporter struct { + // Client is the client to the config api. + *Client +} + +// ReportAzureSEVSNPVersion uploads the latest observed version numbers of the Azure SEVSNP. This version is used to later report the latest version numbers to the API. +// TODO(elchead): use a s3 client without cache invalidation. +func (r Reporter) ReportAzureSEVSNPVersion(ctx context.Context, version AzureSEVSNPVersion, date time.Time) error { + dateStr := date.Format(VersionFormat) + ".json" + res := putCmdWithoutSigning{ + apiObject: reportedAzureSEVSNPVersionAPI{Version: dateStr, AzureSEVSNPVersion: version}, + } + return res.Execute(ctx, r.s3Client) +} + +func (r Reporter) listReportedVersions(ctx context.Context, timeFrame time.Duration) ([]string, error) { + list, err := r.s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(r.bucketID), + Prefix: aws.String(reportVersionDir), + }) + 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 filterDatesWithinTime(dates, time.Now(), timeFrame), nil +} + +// UpdateLatestVersion checks the reported version values +// and updates the latest version of the Azure SEVSNP in the API if there is an update . +func (r Reporter) UpdateLatestVersion(ctx context.Context, latestAPIVersion AzureSEVSNPVersion) error { + // get the reported version values of the last 3 weeks + // if the list is smaller than 3, return with nil + // find the smallest version number of the version list + // upload that version number to the API + + // get the reported version values of the last 3 weeks + versionDates, err := r.listReportedVersions(ctx, timeFrameForCachedVersions) + if err != nil { + return fmt.Errorf("list reported versions: %w", err) + } + r.s3Client.Logger.Infof("Found %d reported versions in the last %s", len(versionDates), timeFrameForCachedVersions.String()) + + minVersion, minDate, err := r.findMinVersion(ctx, versionDates) + if err != nil { + return fmt.Errorf("get minimal version: %w", err) + } + r.s3Client.Logger.Infof("Found minimal version: %+v with date: %s", *minVersion, minDate) + shouldUpdateAPI, err := isInputNewerThanLatestAPI(*minVersion, latestAPIVersion) + if err == nil && shouldUpdateAPI { + r.s3Client.Logger.Infof("Input version: %+v is newer than latest API version: %+v", *minVersion, latestAPIVersion) + // upload minVersion to the API + t, err := time.Parse(VersionFormat, minDate) + if err != nil { + return fmt.Errorf("parsing date: %w", err) + } + // TODO(elchead): defer upload to client so that the Reporter can be decoupled from the client. + if err := r.UploadAzureSEVSNPVersion(ctx, *minVersion, t); err != nil { + return fmt.Errorf("uploading version: %w", err) + } + return nil + } + r.s3Client.Logger.Infof("Input version: %+v is not newer than latest API version: %+v", *minVersion, latestAPIVersion) + return nil +} + +func (r Reporter) findMinVersion(ctx context.Context, versionDates []string) (*AzureSEVSNPVersion, string, error) { + var minimalVersion *AzureSEVSNPVersion + var minimalDate string + sort.Sort(sort.Reverse(sort.StringSlice(versionDates))) // the latest date with the minimal version should be taken + for _, date := range versionDates { + obj, err := client.Fetch(ctx, r.s3Client, reportedAzureSEVSNPVersionAPI{Version: date + ".json"}) + if err != nil { + return nil, "", fmt.Errorf("get object: %w", err) + } + + if minimalVersion == nil { + minimalVersion = &obj.AzureSEVSNPVersion + minimalDate = date + } else { + shouldUpdateMinimal, err := isInputNewerThanLatestAPI(*minimalVersion, obj.AzureSEVSNPVersion) + if err != nil { + return nil, "", fmt.Errorf("comparing versions: %w", err) + } + if shouldUpdateMinimal { + minimalVersion = &obj.AzureSEVSNPVersion + minimalDate = date + } + } + } + return minimalVersion, minimalDate, nil +} + +func filterDatesWithinTime(dates []string, now time.Time, timeFrame time.Duration) []string { + var datesWithinTimeFrame []string + for _, date := range dates { + t, err := time.Parse(VersionFormat, date) + if err != nil { + continue + } + if now.Sub(t) <= timeFrame { + datesWithinTimeFrame = append(datesWithinTimeFrame, date) + } + } + return datesWithinTimeFrame +} + +// isInputNewerThanLatestAPI compares all version fields with the latest API version and returns true if any input field is newer. +func isInputNewerThanLatestAPI(input, latest AzureSEVSNPVersion) (bool, error) { + if input == latest { + return false, nil + } + if input.TEE < latest.TEE { + return false, fmt.Errorf("input TEE version: %d is older than latest API version: %d", input.TEE, latest.TEE) + } + if input.SNP < latest.SNP { + return false, fmt.Errorf("input SNP version: %d is older than latest API version: %d", input.SNP, latest.SNP) + } + if input.Microcode < latest.Microcode { + return false, fmt.Errorf("input Microcode version: %d is older than latest API version: %d", input.Microcode, latest.Microcode) + } + if input.Bootloader < latest.Bootloader { + return false, fmt.Errorf("input Bootloader version: %d is older than latest API version: %d", input.Bootloader, latest.Bootloader) + } + return true, nil +} + +// reportedAzureSEVSNPVersionAPI is the request to get the version information of the specific version in the config api. +type reportedAzureSEVSNPVersionAPI struct { + Version string `json:"-"` + AzureSEVSNPVersion +} + +// URL returns the URL for the request to the config api. +func (i reportedAzureSEVSNPVersionAPI) URL() (string, error) { + return getURL(i) +} + +// JSONPath returns the path to the JSON file for the request to the config api. +func (i reportedAzureSEVSNPVersionAPI) JSONPath() string { + return path.Join(reportVersionDir, i.Version) +} + +// ValidateRequest validates the request. +func (i reportedAzureSEVSNPVersionAPI) 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 reportedAzureSEVSNPVersionAPI) Validate() error { + return nil +} + +type putCmdWithoutSigning struct { + apiObject apiclient.APIObject +} + +func (p putCmdWithoutSigning) Execute(ctx context.Context, c *apiclient.Client) error { + return apiclient.Update(ctx, c, p.apiObject) +} diff --git a/internal/api/attestationconfigapi/reporter_test.go b/internal/api/attestationconfigapi/reporter_test.go new file mode 100644 index 00000000000..ca89913b518 --- /dev/null +++ b/internal/api/attestationconfigapi/reporter_test.go @@ -0,0 +1,50 @@ +/* +Copyright (c) Edgeless Systems GmbH +SPDX-License-Identifier: AGPL-3.0-only +*/ +package attestationconfigapi + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestFilterDatesWithinTime(t *testing.T) { + dates := []string{ + "2022-01-01-00-00", + "2022-01-02-00-00", + "2022-01-03-00-00", + "2022-01-04-00-00", + "2022-01-05-00-00", + "2022-01-06-00-00", + "2022-01-07-00-00", + "2022-01-08-00-00", + } + now := time.Date(2022, 1, 9, 0, 0, 0, 0, time.UTC) + testCases := map[string]struct { + timeFrame time.Duration + expectedDates []string + }{ + "all dates within 3 days": { + timeFrame: time.Hour * 24 * 3, + expectedDates: []string{"2022-01-06-00-00", "2022-01-07-00-00", "2022-01-08-00-00"}, + }, + "no dates within time frame": { + timeFrame: time.Hour, + expectedDates: nil, + }, + "some dates within time frame": { + timeFrame: time.Hour * 24 * 4, + expectedDates: []string{"2022-01-05-00-00", "2022-01-06-00-00", "2022-01-07-00-00", "2022-01-08-00-00"}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + filteredDates := filterDatesWithinTime(dates, now, tc.timeFrame) + assert.Equal(t, tc.expectedDates, filteredDates) + }) + } +}