diff --git a/.github/actions/e2e_verify/action.yml b/.github/actions/e2e_verify/action.yml index c7bc24086..6e3c3c0c6 100644 --- a/.github/actions/e2e_verify/action.yml +++ b/.github/actions/e2e_verify/action.yml @@ -97,5 +97,5 @@ runs: for file in $(ls maa-claims-*.json); do path=$(realpath "${file}") cat "${path}" - bazel run //internal/api/attestationconfigapi -- --maa-claims-path "${path}" + bazel run //internal/api/attestationconfigapi/cli -- --maa-claims-path "${path}" done diff --git a/cli/internal/cmd/configfetchmeasurements.go b/cli/internal/cmd/configfetchmeasurements.go index e08c6793b..7a3ed1a48 100644 --- a/cli/internal/cmd/configfetchmeasurements.go +++ b/cli/internal/cmd/configfetchmeasurements.go @@ -71,7 +71,7 @@ func runConfigFetchMeasurements(cmd *cobra.Command, _ []string) error { } cfm := &configFetchMeasurementsCmd{log: log, canFetchMeasurements: featureset.CanFetchMeasurements} - fetcher := attestationconfigapi.NewFetcherWithClient(http.DefaultClient) + fetcher := attestationconfigapi.NewFetcherWithClient(http.DefaultClient, constants.CDNRepositoryURL) return cfm.configFetchMeasurements(cmd, sigstore.NewCosignVerifier, rekor, fileHandler, fetcher, http.DefaultClient) } diff --git a/cli/internal/cmd/configfetchmeasurements_test.go b/cli/internal/cmd/configfetchmeasurements_test.go index f11159a1a..0b5b54980 100644 --- a/cli/internal/cmd/configfetchmeasurements_test.go +++ b/cli/internal/cmd/configfetchmeasurements_test.go @@ -15,7 +15,6 @@ import ( "net/url" "strconv" "testing" - "time" "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" "github.com/edgelesssys/constellation/v2/internal/api/versionsapi" @@ -307,7 +306,7 @@ func (f stubAttestationFetcher) FetchAzureSEVSNPVersion(_ context.Context, _ att }, nil } -func (f stubAttestationFetcher) FetchAzureSEVSNPVersionLatest(_ context.Context, _ time.Time) (attestationconfigapi.AzureSEVSNPVersionAPI, error) { +func (f stubAttestationFetcher) FetchAzureSEVSNPVersionLatest(_ context.Context) (attestationconfigapi.AzureSEVSNPVersionAPI, error) { return attestationconfigapi.AzureSEVSNPVersionAPI{ AzureSEVSNPVersion: testCfg, }, nil diff --git a/cli/internal/cmd/iamupgradeapply_test.go b/cli/internal/cmd/iamupgradeapply_test.go index ba49c25ca..f3b451ae5 100644 --- a/cli/internal/cmd/iamupgradeapply_test.go +++ b/cli/internal/cmd/iamupgradeapply_test.go @@ -11,7 +11,6 @@ import ( "path/filepath" "strings" "testing" - "time" "github.com/edgelesssys/constellation/v2/cli/internal/terraform" "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" @@ -176,6 +175,6 @@ func (s *stubConfigFetcher) FetchAzureSEVSNPVersionList(context.Context, attesta panic("not implemented") } -func (s *stubConfigFetcher) FetchAzureSEVSNPVersionLatest(context.Context, time.Time) (attestationconfigapi.AzureSEVSNPVersionAPI, error) { +func (s *stubConfigFetcher) FetchAzureSEVSNPVersionLatest(context.Context) (attestationconfigapi.AzureSEVSNPVersionAPI, error) { return attestationconfigapi.AzureSEVSNPVersionAPI{}, s.fetchLatestErr } diff --git a/dev-docs/workflows/attestationconfigapi.md b/dev-docs/workflows/attestationconfigapi.md new file mode 100644 index 000000000..5da8eda35 --- /dev/null +++ b/dev-docs/workflows/attestationconfigapi.md @@ -0,0 +1,17 @@ +# Attestation config API + +## Azure SEV-SNP +The version numbers of SEV-SNP are updated as part of [e2e_verify](/.github/actions/e2e_verify/action.yml). +Because the version numbers are not publicly posted by Azure, we observe the versions on Azure VMs and assume a global rollout after a threshold time. + +This estimate might make manual intervention necessary when a global rollout didn't happen. + +### Manually delete a version +``` +COSIGN_PASSWORD=$CPW COSIGN_PRIVATE_KEY="$(cat $PATH_TO_KEY)" AWS_ACCESS_KEY_ID=$ID AWS_ACCESS_KEY=$KEY bazel run //internal/api/attestationconfigapi/cli delete -- --version 2023-09-02-12-52 +``` + +### Manually upload a version +``` +COSIGN_PASSWORD=$CPW COSIGN_PRIVATE_KEY="$(cat $PATH_TO_KEY)" AWS_ACCESS_KEY_ID=$ID AWS_ACCESS_KEY=$KEY bazel run //internal/api/attestationconfigapi/cli -- --force --version 2023-09-02-12-52 --maa-claims-path "${path}" +``` diff --git a/go.mod b/go.mod index c350c9579..1cd0017f0 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 0884227d3..95ebb0a1c 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 d1d43081e..d92f26ba3 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= @@ -1159,6 +1161,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= @@ -1265,6 +1268,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= @@ -1274,6 +1278,7 @@ golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= golang.org/x/sys v0.12.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 145ff6461..da62b490a 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,7 +30,11 @@ go_test( srcs = [ "client_test.go", "fetcher_test.go", + "reporter_test.go", ], embed = [":attestationconfigapi"], - deps = ["@com_github_stretchr_testify//assert"], + deps = [ + "//internal/constants", + "@com_github_stretchr_testify//assert", + ], ) diff --git a/internal/api/attestationconfigapi/azure.go b/internal/api/attestationconfigapi/azure.go index 0530126ed..f915420a1 100644 --- a/internal/api/attestationconfigapi/azure.go +++ b/internal/api/attestationconfigapi/azure.go @@ -8,13 +8,11 @@ package attestationconfigapi import ( "fmt" - "net/url" "path" "sort" "strings" "github.com/edgelesssys/constellation/v2/internal/attestation/variant" - "github.com/edgelesssys/constellation/v2/internal/constants" ) // attestationURLPath is the URL path to the attestation versions. @@ -41,11 +39,6 @@ type AzureSEVSNPVersionAPI struct { AzureSEVSNPVersion } -// URL returns the URL for the request to the config api. -func (i AzureSEVSNPVersionAPI) URL() (string, error) { - return getURL(i) -} - // JSONPath returns the path to the JSON file for the request to the config api. func (i AzureSEVSNPVersionAPI) JSONPath() string { return path.Join(attestationURLPath, variant.AzureSEVSNP{}.String(), i.Version) @@ -67,11 +60,6 @@ func (i AzureSEVSNPVersionAPI) Validate() error { // AzureSEVSNPVersionList is the request to list all versions in the config api. type AzureSEVSNPVersionList []string -// URL returns the URL for the request to the config api. -func (i AzureSEVSNPVersionList) URL() (string, error) { - return getURL(i) -} - // JSONPath returns the path to the JSON file for the request to the config api. func (i AzureSEVSNPVersionList) JSONPath() string { return path.Join(attestationURLPath, variant.AzureSEVSNP{}.String(), "list") @@ -94,16 +82,3 @@ func (i AzureSEVSNPVersionList) Validate() error { } return nil } - -func getURL(obj jsoPather) (string, error) { - url, err := url.Parse(constants.CDNRepositoryURL) - if err != nil { - return "", fmt.Errorf("parsing CDN URL: %w", err) - } - url.Path = obj.JSONPath() - return url.String(), nil -} - -type jsoPather interface { - JSONPath() string -} diff --git a/internal/api/attestationconfigapi/cli/BUILD.bazel b/internal/api/attestationconfigapi/cli/BUILD.bazel index e293131bd..d1eefcc55 100644 --- a/internal/api/attestationconfigapi/cli/BUILD.bazel +++ b/internal/api/attestationconfigapi/cli/BUILD.bazel @@ -21,6 +21,9 @@ go_library( "//internal/constants", "//internal/logger", "//internal/staticupload", + "@com_github_aws_aws_sdk_go//aws", + "@com_github_aws_aws_sdk_go_v2_service_s3//:s3", + "@com_github_aws_aws_sdk_go_v2_service_s3//types", "@com_github_spf13_cobra//:cobra", "@org_uber_go_zap//:zap", ], @@ -28,13 +31,9 @@ go_library( go_test( name = "cli_test", - srcs = [ - "delete_test.go", - "main_test.go", - ], + srcs = ["delete_test.go"], embed = [":cli_lib"], deps = [ - "//internal/api/attestationconfigapi", "@com_github_stretchr_testify//assert", "@com_github_stretchr_testify//require", ], diff --git a/internal/api/attestationconfigapi/cli/delete.go b/internal/api/attestationconfigapi/cli/delete.go index e3e2c2000..05df3e6dd 100644 --- a/internal/api/attestationconfigapi/cli/delete.go +++ b/internal/api/attestationconfigapi/cli/delete.go @@ -10,6 +10,9 @@ import ( "errors" "fmt" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/aws-sdk-go/aws" "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" "github.com/edgelesssys/constellation/v2/internal/logger" "github.com/edgelesssys/constellation/v2/internal/staticupload" @@ -27,6 +30,13 @@ func newDeleteCmd() *cobra.Command { } cmd.Flags().StringP("version", "v", "", "Name of the version to delete (without .json suffix)") must(cmd.MarkFlagRequired("version")) + + recursivelyCmd := &cobra.Command{ + Use: "recursive", + Short: "delete all objects from the API path", + RunE: runRecursiveDelete, + } + cmd.AddCommand(recursivelyCmd) return cmd } @@ -59,17 +69,19 @@ func runDelete(cmd *cobra.Command, _ []string) (retErr error) { return fmt.Errorf("getting bucket: %w", err) } - distribution, err := cmd.Flags().GetString("distribution") + testing, err := cmd.Flags().GetBool("testing") if err != nil { - return fmt.Errorf("getting distribution: %w", err) + return fmt.Errorf("getting testing flag: %w", err) } + _, distribution := getEnvironment(testing) cfg := staticupload.Config{ Bucket: bucket, Region: region, DistributionID: distribution, } - client, clientClose, err := attestationconfigapi.NewClient(cmd.Context(), cfg, []byte(cosignPwd), []byte(privateKey), false, log) + client, clientClose, err := attestationconfigapi.NewClient(cmd.Context(), cfg, + []byte(cosignPwd), []byte(privateKey), false, 1, log) if err != nil { return fmt.Errorf("create attestation client: %w", err) } @@ -85,3 +97,64 @@ func runDelete(cmd *cobra.Command, _ []string) (retErr error) { } return deleteCmd.delete(cmd) } + +func runRecursiveDelete(cmd *cobra.Command, _ []string) (retErr error) { + region, err := cmd.Flags().GetString("region") + if err != nil { + return fmt.Errorf("getting region: %w", err) + } + + bucket, err := cmd.Flags().GetString("bucket") + if err != nil { + return fmt.Errorf("getting bucket: %w", err) + } + + testing, err := cmd.Flags().GetBool("testing") + if err != nil { + return fmt.Errorf("getting testing flag: %w", err) + } + _, distribution := getEnvironment(testing) + + log := logger.New(logger.PlainLog, zap.DebugLevel).Named("attestationconfigapi") + client, closeFn, err := staticupload.New(cmd.Context(), staticupload.Config{ + Bucket: bucket, + Region: region, + DistributionID: distribution, + }, log) + if err != nil { + return fmt.Errorf("create static upload client: %w", err) + } + defer func() { + err := closeFn(cmd.Context()) + if err != nil { + retErr = errors.Join(retErr, fmt.Errorf("failed to close client: %w", err)) + } + }() + path := "constellation/v1/attestation/azure-sev-snp" + resp, err := client.ListObjectsV2(cmd.Context(), &s3.ListObjectsV2Input{ + Bucket: aws.String(bucket), + Prefix: aws.String(path), + }) + if err != nil { + return err + } + + // Delete all objects in the path. + objIDs := make([]s3types.ObjectIdentifier, len(resp.Contents)) + for i, obj := range resp.Contents { + objIDs[i] = s3types.ObjectIdentifier{Key: obj.Key} + } + if len(objIDs) > 0 { + _, err = client.DeleteObjects(cmd.Context(), &s3.DeleteObjectsInput{ + Bucket: aws.String(bucket), + Delete: &s3types.Delete{ + Objects: objIDs, + Quiet: true, + }, + }) + if err != nil { + return err + } + } + return nil +} diff --git a/internal/api/attestationconfigapi/cli/e2e/test.sh.in b/internal/api/attestationconfigapi/cli/e2e/test.sh.in index d132780fe..5382b6725 100755 --- a/internal/api/attestationconfigapi/cli/e2e/test.sh.in +++ b/internal/api/attestationconfigapi/cli/e2e/test.sh.in @@ -17,58 +17,123 @@ fi configapi_cli=$(realpath @@CONFIGAPI_CLI@@) stat "${configapi_cli}" >> /dev/null - +configapi_cli="${configapi_cli} --testing" ###### script body ###### readonly region="eu-west-1" readonly bucket="resource-api-testing" -readonly distribution="ETZGUP1CWRC2P" tmpdir=$(mktemp -d) readonly tmpdir registerExitHandler "rm -rf $tmpdir" +# empty the bucket version state +${configapi_cli} delete recursive --region "$region" --bucket "$bucket" + +# the high version numbers ensure that it's newer than the current latest value +readonly current_claim_path="$tmpdir/currentMaaClaim.json" +cat << EOF > "$current_claim_path" +{ + "x-ms-isolation-tee": { + "x-ms-sevsnpvm-tee-svn": 1, + "x-ms-sevsnpvm-snpfw-svn": 1, + "x-ms-sevsnpvm-microcode-svn": 1, + "x-ms-sevsnpvm-bootloader-svn": 1 + } +} +EOF +# upload a fake latest version for the fetcher +${configapi_cli} --force --maa-claims-path "$current_claim_path" --upload-date "2000-01-01-01-01" --region "$region" --bucket "$bucket" + +# the high version numbers ensure that it's newer than the current latest value readonly claim_path="$tmpdir/maaClaim.json" cat << EOF > "$claim_path" { "x-ms-isolation-tee": { - "x-ms-sevsnpvm-tee-svn": 1, - "x-ms-sevsnpvm-snpfw-svn": 9, - "x-ms-sevsnpvm-microcode-svn": 116, - "x-ms-sevsnpvm-bootloader-svn": 4 + "x-ms-sevsnpvm-tee-svn": 255, + "x-ms-sevsnpvm-snpfw-svn": 255, + "x-ms-sevsnpvm-microcode-svn": 255, + "x-ms-sevsnpvm-bootloader-svn": 255 } } EOF -readonly date="2023-02-02-03-04" -${configapi_cli} --maa-claims-path "$claim_path" --upload-date "$date" --region "$region" --bucket "$bucket" --distribution "$distribution" +# has an older version +readonly older_claim_path="$tmpdir/maaClaimOld.json" +cat << EOF > "$older_claim_path" +{ + "x-ms-isolation-tee": { + "x-ms-sevsnpvm-tee-svn": 255, + "x-ms-sevsnpvm-snpfw-svn": 255, + "x-ms-sevsnpvm-microcode-svn": 254, + "x-ms-sevsnpvm-bootloader-svn": 255 + } +} +EOF +# report 3 versions with different dates to fill the reporter cache +readonly date_oldest="2023-02-01-03-04" +${configapi_cli} --maa-claims-path "$older_claim_path" --upload-date "$date_oldest" --region "$region" --bucket "$bucket" --cache-window-size 3 +readonly date_older="2023-02-02-03-04" +${configapi_cli} --maa-claims-path "$older_claim_path" --upload-date "$date_older" --region "$region" --bucket "$bucket" --cache-window-size 3 +readonly date="2023-02-03-03-04" +${configapi_cli} --maa-claims-path "$claim_path" --upload-date "$date" --region "$region" --bucket "$bucket" --cache-window-size 3 + +# expect that $date_oldest is served as latest version baseurl="https://d33dzgxuwsgbpw.cloudfront.net/constellation/v1/attestation/azure-sev-snp" -if ! curl -fsSL ${baseurl}/${date}.json > /dev/null; then - echo "Checking for uploaded version file constellation/v1/attestation/azure-sev-snp/${date}.json: request returned ${?}" +if ! curl -fsSL ${baseurl}/${date_oldest}.json > version.json; then + echo "Checking for uploaded version file constellation/v1/attestation/azure-sev-snp/${date_oldest}.json: request returned ${?}" exit 1 fi - -if ! curl -fsSL ${baseurl}/${date}.json.sig > /dev/null; then - echo "Checking for uploaded version signature file constellation/v1/attestation/azure-sev-snp/${date}.json.sig: request returned ${?}" +# check that version values are equal to expected +if ! cmp -s <(echo -n '{"bootloader":255,"tee":255,"snp":255,"microcode":254}') version.json; then + echo "The version content:" + cat version.json + echo " is not equal to the expected version content:" + echo '{"bootloader":255,"tee":255,"snp":255,"microcode":254}' exit 1 fi - -if ! curl -fsSL ${baseurl}/list > /dev/null; then +if ! curl -fsSL ${baseurl}/${date_oldest}.json.sig > /dev/null; then + echo "Checking for uploaded version signature file constellation/v1/attestation/azure-sev-snp/${date_oldest}.json.sig: request returned ${?}" + exit 1 +fi +# check list endpoint +if ! curl -fsSL ${baseurl}/list > list.json; then echo "Checking for uploaded list file constellation/v1/attestation/azure-sev-snp/list: request returned ${?}" exit 1 fi -${configapi_cli} delete --version "$date" --region "$region" --bucket "$bucket" --distribution "$distribution" - -# Omit -f to check for 404. We want to check that a file was deleted, therefore we expect the query to fail. -http_code=$(curl -sSL -w '%{http_code}\n' -o /dev/null ${baseurl}/${date}.json) -if [[ $http_code -ne 404 ]]; then - echo "Expected HTTP code 404 for: constellation/v1/attestation/azure-sev-snp/${date}.json, but got ${http_code}" +# check that version values are equal to expected +if ! cmp -s <(echo -n '["2023-02-01-03-04.json","2000-01-01-01-01.json"]') list.json; then + echo "The list content:" + cat list.json + echo " is not equal to the expected version content:" + echo '["2023-02-01-03-04.json","2000-01-01-01-01.json"]' + exit 1 +fi + +# check that the other versions are not uploaded +http_code=$(curl -sSL -w '%{http_code}\n' -o /dev/null ${baseurl}/${date_older}.json) +if [[ $http_code -ne 404 ]]; then + echo "Expected HTTP code 404 for: constellation/v1/attestation/azure-sev-snp/${date_older}.json, but got ${http_code}" exit 1 fi -# Omit -f to check for 404. We want to check that a file was deleted, therefore we expect the query to fail. http_code=$(curl -sSL -w '%{http_code}\n' -o /dev/null ${baseurl}/${date}.json.sig) if [[ $http_code -ne 404 ]]; then echo "Expected HTTP code 404 for: constellation/v1/attestation/azure-sev-snp/${date}.json, but got ${http_code}" exit 1 fi + +${configapi_cli} delete --version "$date_oldest" --region "$region" --bucket "$bucket" + +# Omit -f to check for 404. We want to check that a file was deleted, therefore we expect the query to fail. +http_code=$(curl -sSL -w '%{http_code}\n' -o /dev/null ${baseurl}/${date_oldest}.json) +if [[ $http_code -ne 404 ]]; then + echo "Expected HTTP code 404 for: constellation/v1/attestation/azure-sev-snp/${date_oldest}.json, but got ${http_code}" + exit 1 +fi +# Omit -f to check for 404. We want to check that a file was deleted, therefore we expect the query to fail. +http_code=$(curl -sSL -w '%{http_code}\n' -o /dev/null ${baseurl}/${date_oldest}.json.sig) +if [[ $http_code -ne 404 ]]; then + echo "Expected HTTP code 404 for: constellation/v1/attestation/azure-sev-snp/${date_oldest}.json, but got ${http_code}" + exit 1 +fi diff --git a/internal/api/attestationconfigapi/cli/main.go b/internal/api/attestationconfigapi/cli/main.go index 424b61ef2..111bcaee6 100644 --- a/internal/api/attestationconfigapi/cli/main.go +++ b/internal/api/attestationconfigapi/cli/main.go @@ -8,8 +8,9 @@ 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. */ package main @@ -35,6 +36,8 @@ const ( distributionID = constants.CDNDefaultDistributionID envCosignPwd = "COSIGN_PASSWORD" envCosignPrivateKey = "COSIGN_PRIVATE_KEY" + // versionWindowSize defines the number of versions to be considered for the latest version. Each week 5 versions are uploaded for each node of the verify cluster. + versionWindowSize = 15 ) var ( @@ -53,10 +56,10 @@ func main() { // newRootCmd creates the root command. func newRootCmd() *cobra.Command { rootCmd := &cobra.Command{ - Use: "COSIGN_PASSWORD=$CPWD COSIGN_PRIVATE_KEY=$CKEY upload --version-file $FILE", + Use: "COSIGN_PASSWORD=$CPW 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 CLI uploads an observed version number specific to the azure-sev-snp attestation variant to a cache directory. The CLI then determines the lowest version within the cache-window present in the cache and writes that value to 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, @@ -66,9 +69,12 @@ func newRootCmd() *cobra.Command { } rootCmd.Flags().StringP("maa-claims-path", "t", "", "File path to a json file containing the MAA claims.") rootCmd.Flags().StringP("upload-date", "d", "", "upload a version with this date as version name.") + rootCmd.Flags().BoolP("force", "f", false, "Use force to manually push a new latest version."+ + " The version gets saved to the cache but the version selection logic is skipped.") + rootCmd.Flags().IntP("cache-window-size", "s", versionWindowSize, "Number of versions to be considered for the latest version.") rootCmd.PersistentFlags().StringP("region", "r", awsRegion, "region of the targeted bucket.") rootCmd.PersistentFlags().StringP("bucket", "b", awsBucket, "bucket targeted by all operations.") - rootCmd.PersistentFlags().StringP("distribution", "i", distributionID, "cloudflare distribution used.") + rootCmd.PersistentFlags().Bool("testing", false, "upload to S3 test bucket.") must(rootCmd.MarkFlagRequired("maa-claims-path")) rootCmd.AddCommand(newDeleteCmd()) return rootCmd @@ -110,23 +116,8 @@ func runCmd(cmd *cobra.Command, _ []string) (retErr error) { inputVersion := maaTCB.ToAzureSEVSNPVersion() log.Infof("Input version: %+v", inputVersion) - latestAPIVersionAPI, err := attestationconfigapi.NewFetcher().FetchAzureSEVSNPVersionLatest(ctx, flags.uploadDate) - if err != nil { - return fmt.Errorf("fetching latest version: %w", err) - } - 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) + client, clientClose, err := attestationconfigapi.NewClient(ctx, cfg, + []byte(cosignPwd), []byte(privateKey), false, flags.cacheWindowSize, log) defer func() { err := clientClose(cmd.Context()) if err != nil { @@ -138,64 +129,98 @@ func runCmd(cmd *cobra.Command, _ []string) (retErr error) { return fmt.Errorf("creating client: %w", err) } - if err := client.UploadAzureSEVSNPVersion(ctx, inputVersion, flags.uploadDate); err != nil { - return fmt.Errorf("uploading version: %w", err) + latestAPIVersionAPI, err := attestationconfigapi.NewFetcherWithCustomCDNAndCosignKey(flags.url, constants.CosignPublicKeyDev).FetchAzureSEVSNPVersionLatest(ctx) + if err != nil { + if errors.Is(err, attestationconfigapi.ErrNoVersionsFound) { + log.Infof("No versions found in API, but assuming that we are uploading the first version.") + } else { + return fmt.Errorf("fetching latest version: %w", err) + } + } + latestAPIVersion := latestAPIVersionAPI.AzureSEVSNPVersion + if err := client.UploadAzureSEVSNPVersionLatest(ctx, inputVersion, latestAPIVersion, flags.uploadDate, flags.force); err != nil { + if errors.Is(err, attestationconfigapi.ErrNoNewerVersion) { + log.Infof("Input version: %+v is not newer than latest API version: %+v", inputVersion, latestAPIVersion) + return nil + } + return fmt.Errorf("updating latest version: %w", err) } - - cmd.Printf("Successfully uploaded new Azure SEV-SNP version: %+v\n", inputVersion) return nil } -type cliFlags struct { - maaFilePath string - uploadDate time.Time - region string - bucket string - distribution string +type config struct { + maaFilePath string + uploadDate time.Time + region string + bucket string + distribution string + url string + force bool + cacheWindowSize int } -func parseCliFlags(cmd *cobra.Command) (cliFlags, error) { +func parseCliFlags(cmd *cobra.Command) (config, error) { maaFilePath, err := cmd.Flags().GetString("maa-claims-path") if err != nil { - return cliFlags{}, fmt.Errorf("getting maa claims path: %w", err) + return config{}, fmt.Errorf("getting maa claims path: %w", err) } dateStr, err := cmd.Flags().GetString("upload-date") if err != nil { - return cliFlags{}, fmt.Errorf("getting upload date: %w", err) + return config{}, fmt.Errorf("getting upload date: %w", err) } uploadDate := time.Now() if dateStr != "" { uploadDate, err = time.Parse(attestationconfigapi.VersionFormat, dateStr) if err != nil { - return cliFlags{}, fmt.Errorf("parsing date: %w", err) + return config{}, fmt.Errorf("parsing date: %w", err) } } region, err := cmd.Flags().GetString("region") if err != nil { - return cliFlags{}, fmt.Errorf("getting region: %w", err) + return config{}, fmt.Errorf("getting region: %w", err) } bucket, err := cmd.Flags().GetString("bucket") if err != nil { - return cliFlags{}, fmt.Errorf("getting bucket: %w", err) + return config{}, fmt.Errorf("getting bucket: %w", err) } - distribution, err := cmd.Flags().GetString("distribution") + testing, err := cmd.Flags().GetBool("testing") if err != nil { - return cliFlags{}, fmt.Errorf("getting distribution: %w", err) + return config{}, fmt.Errorf("getting testing flag: %w", err) + } + url, distribution := getEnvironment(testing) + + force, err := cmd.Flags().GetBool("force") + if err != nil { + return config{}, fmt.Errorf("getting force: %w", err) } - return cliFlags{ - maaFilePath: maaFilePath, - uploadDate: uploadDate, - region: region, - bucket: bucket, - distribution: distribution, + cacheWindowSize, err := cmd.Flags().GetInt("cache-window-size") + if err != nil { + return config{}, fmt.Errorf("getting cache window size: %w", err) + } + return config{ + maaFilePath: maaFilePath, + uploadDate: uploadDate, + region: region, + bucket: bucket, + url: url, + distribution: distribution, + force: force, + cacheWindowSize: cacheWindowSize, }, nil } +func getEnvironment(testing bool) (url string, distributionID string) { + if testing { + return "https://d33dzgxuwsgbpw.cloudfront.net", "ETZGUP1CWRC2P" + } + return constants.CDNRepositoryURL, constants.CDNDefaultDistributionID +} + // maaTokenTCBClaims describes the TCB information in a MAA token. type maaTokenTCBClaims struct { IsolationTEE struct { @@ -215,26 +240,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/client.go b/internal/api/attestationconfigapi/client.go index 7356f1e74..f6ecf96d6 100644 --- a/internal/api/attestationconfigapi/client.go +++ b/internal/api/attestationconfigapi/client.go @@ -7,6 +7,7 @@ package attestationconfigapi import ( "context" + "errors" "fmt" "time" @@ -14,6 +15,7 @@ import ( "github.com/edgelesssys/constellation/v2/internal/attestation/variant" "github.com/edgelesssys/constellation/v2/internal/logger" "github.com/edgelesssys/constellation/v2/internal/sigstore" + "github.com/edgelesssys/constellation/v2/internal/staticupload" ) @@ -22,30 +24,32 @@ const VersionFormat = "2006-01-02-15-04" // Client manages (modifies) the version information for the attestation variants. type Client struct { - s3Client *apiclient.Client - s3ClientClose func(ctx context.Context) error - bucketID string - signer sigstore.Signer + s3Client *apiclient.Client + s3ClientClose func(ctx context.Context) error + bucketID string + signer sigstore.Signer + cacheWindowSize int } // NewClient returns a new Client. -func NewClient(ctx context.Context, cfg staticupload.Config, cosignPwd, privateKey []byte, dryRun bool, log *logger.Logger) (*Client, apiclient.CloseFunc, error) { +func NewClient(ctx context.Context, cfg staticupload.Config, cosignPwd, privateKey []byte, dryRun bool, versionWindowSize int, log *logger.Logger) (*Client, apiclient.CloseFunc, error) { s3Client, clientClose, err := apiclient.NewClient(ctx, cfg.Region, cfg.Bucket, cfg.DistributionID, dryRun, log) if err != nil { return nil, nil, fmt.Errorf("failed to create s3 storage: %w", err) } repo := &Client{ - s3Client: s3Client, - s3ClientClose: clientClose, - signer: sigstore.NewSigner(cosignPwd, privateKey), - bucketID: cfg.Bucket, + s3Client: s3Client, + s3ClientClose: clientClose, + signer: sigstore.NewSigner(cosignPwd, privateKey), + bucketID: cfg.Bucket, + cacheWindowSize: versionWindowSize, } return repo, clientClose, nil } -// UploadAzureSEVSNPVersion uploads the latest version numbers of the Azure SEVSNP. Then version name is the UTC timestamp of the date. The /list entry stores the version name + .json suffix. -func (a Client) UploadAzureSEVSNPVersion(ctx context.Context, version AzureSEVSNPVersion, date time.Time) error { +// uploadAzureSEVSNPVersion uploads the latest version numbers of the Azure SEVSNP. Then version name is the UTC timestamp of the date. The /list entry stores the version name + .json suffix. +func (a Client) uploadAzureSEVSNPVersion(ctx context.Context, version AzureSEVSNPVersion, date time.Time) error { versions, err := a.List(ctx, variant.AzureSEVSNP{}) if err != nil { return fmt.Errorf("fetch version list: %w", err) @@ -73,6 +77,10 @@ func (a Client) List(ctx context.Context, attestation variant.Variant) ([]string if attestation.Equal(variant.AzureSEVSNP{}) { versions, err := apiclient.Fetch(ctx, a.s3Client, AzureSEVSNPVersionList{}) if err != nil { + var notFoundErr *apiclient.NotFoundError + if errors.As(err, ¬FoundErr) { + return nil, nil + } return nil, err } return versions, nil diff --git a/internal/api/attestationconfigapi/fetcher.go b/internal/api/attestationconfigapi/fetcher.go index fe19154e5..3ea9c5430 100644 --- a/internal/api/attestationconfigapi/fetcher.go +++ b/internal/api/attestationconfigapi/fetcher.go @@ -8,97 +8,93 @@ package attestationconfigapi import ( "context" + "errors" "fmt" - "strings" - "time" apifetcher "github.com/edgelesssys/constellation/v2/internal/api/fetcher" "github.com/edgelesssys/constellation/v2/internal/constants" "github.com/edgelesssys/constellation/v2/internal/sigstore" ) -// minimumAgeVersion is the minimum age to accept the version as latest. -const minimumAgeVersion = 14 * 24 * time.Hour - const cosignPublicKey = constants.CosignPublicKeyReleases +// ErrNoVersionsFound is returned if no versions are found. +var ErrNoVersionsFound = errors.New("no versions found") + // Fetcher fetches config API resources without authentication. type Fetcher interface { FetchAzureSEVSNPVersion(ctx context.Context, azureVersion AzureSEVSNPVersionAPI) (AzureSEVSNPVersionAPI, error) FetchAzureSEVSNPVersionList(ctx context.Context, attestation AzureSEVSNPVersionList) (AzureSEVSNPVersionList, error) - FetchAzureSEVSNPVersionLatest(ctx context.Context, now time.Time) (AzureSEVSNPVersionAPI, error) + FetchAzureSEVSNPVersionLatest(ctx context.Context) (AzureSEVSNPVersionAPI, error) } // fetcher fetches AttestationCfg API resources without authentication. type fetcher struct { apifetcher.HTTPClient + cdnURL string verifier sigstore.Verifier } // NewFetcher returns a new apifetcher. func NewFetcher() Fetcher { - return NewFetcherWithClient(apifetcher.NewHTTPClient()) + return NewFetcherWithClient(apifetcher.NewHTTPClient(), constants.CDNRepositoryURL) +} + +// NewFetcherWithCustomCDNAndCosignKey returns a new fetcher with custom CDN URL. +func NewFetcherWithCustomCDNAndCosignKey(cdnURL, cosignKey string) Fetcher { + verifier, err := sigstore.NewCosignVerifier([]byte(cosignKey)) + if err != nil { + // This relies on an embedded public key. If this key can not be validated, there is no way to recover from this. + panic(fmt.Errorf("creating cosign verifier: %w", err)) + } + return newFetcherWithClientAndVerifier(apifetcher.NewHTTPClient(), verifier, cdnURL) } // NewFetcherWithClient returns a new fetcher with custom http client. -func NewFetcherWithClient(client apifetcher.HTTPClient) Fetcher { +func NewFetcherWithClient(client apifetcher.HTTPClient, cdnURL string) Fetcher { verifier, err := sigstore.NewCosignVerifier([]byte(cosignPublicKey)) if err != nil { // This relies on an embedded public key. If this key can not be validated, there is no way to recover from this. panic(fmt.Errorf("creating cosign verifier: %w", err)) } - return newFetcherWithClientAndVerifier(client, verifier) + return newFetcherWithClientAndVerifier(client, verifier, cdnURL) } -func newFetcherWithClientAndVerifier(client apifetcher.HTTPClient, cosignVerifier sigstore.Verifier) Fetcher { - return &fetcher{client, cosignVerifier} +func newFetcherWithClientAndVerifier(client apifetcher.HTTPClient, cosignVerifier sigstore.Verifier, url string) Fetcher { + return &fetcher{HTTPClient: client, verifier: cosignVerifier, cdnURL: url} } // FetchAzureSEVSNPVersionList fetches the version list information from the config API. func (f *fetcher) FetchAzureSEVSNPVersionList(ctx context.Context, attestation AzureSEVSNPVersionList) (AzureSEVSNPVersionList, error) { // TODO(derpsteb): Replace with FetchAndVerify once we move to v2 of the config API. - return apifetcher.Fetch(ctx, f.HTTPClient, attestation) + return apifetcher.Fetch(ctx, f.HTTPClient, f.cdnURL, attestation) } // FetchAzureSEVSNPVersion fetches the version information from the config API. func (f *fetcher) FetchAzureSEVSNPVersion(ctx context.Context, azureVersion AzureSEVSNPVersionAPI) (AzureSEVSNPVersionAPI, error) { - fetchedVersion, err := apifetcher.FetchAndVerify(ctx, f.HTTPClient, azureVersion, f.verifier) + fetchedVersion, err := apifetcher.FetchAndVerify(ctx, f.HTTPClient, f.cdnURL, azureVersion, f.verifier) if err != nil { - return fetchedVersion, fmt.Errorf("fetch version %s: %w", fetchedVersion.Version, err) + return fetchedVersion, fmt.Errorf("fetching version %s: %w", azureVersion.Version, err) } - return fetchedVersion, nil } // FetchAzureSEVSNPVersionLatest returns the latest versions of the given type. -func (f *fetcher) FetchAzureSEVSNPVersionLatest(ctx context.Context, now time.Time) (res AzureSEVSNPVersionAPI, err error) { +func (f *fetcher) FetchAzureSEVSNPVersionLatest(ctx context.Context) (res AzureSEVSNPVersionAPI, err error) { var list AzureSEVSNPVersionList list, err = f.FetchAzureSEVSNPVersionList(ctx, list) if err != nil { - return res, fmt.Errorf("fetching versions list: %w", err) + return res, ErrNoVersionsFound } - getVersionRequest, err := getLatestVersionOlderThanMinimumAge(list, now, minimumAgeVersion) - if err != nil { - return res, fmt.Errorf("finding latest valid version: %w", err) + if len(list) < 1 { + return res, ErrNoVersionsFound + } + getVersionRequest := AzureSEVSNPVersionAPI{ + Version: list[0], // latest version is first in list } res, err = f.FetchAzureSEVSNPVersion(ctx, getVersionRequest) if err != nil { - return res, fmt.Errorf("fetching version: %w", err) + return res, err } return } - -func getLatestVersionOlderThanMinimumAge(list AzureSEVSNPVersionList, now time.Time, minimumAgeVersion time.Duration) (AzureSEVSNPVersionAPI, error) { - SortAzureSEVSNPVersionList(list) - for _, v := range list { - dateStr := strings.TrimSuffix(v, ".json") - versionDate, err := time.Parse(VersionFormat, dateStr) - if err != nil { - return AzureSEVSNPVersionAPI{}, fmt.Errorf("parsing version date %s: %w", dateStr, err) - } - if now.Sub(versionDate) > minimumAgeVersion { - return AzureSEVSNPVersionAPI{Version: v}, nil - } - } - return AzureSEVSNPVersionAPI{}, fmt.Errorf("no valid version fulfilling minimum age found") -} diff --git a/internal/api/attestationconfigapi/fetcher_test.go b/internal/api/attestationconfigapi/fetcher_test.go index 79f126c1a..6e3f51eec 100644 --- a/internal/api/attestationconfigapi/fetcher_test.go +++ b/internal/api/attestationconfigapi/fetcher_test.go @@ -16,11 +16,11 @@ import ( "testing" "time" + "github.com/edgelesssys/constellation/v2/internal/constants" "github.com/stretchr/testify/assert" ) func TestFetchLatestAzureSEVSNPVersion(t *testing.T) { - now := time.Date(2023, 6, 12, 0, 0, 0, 0, time.UTC) latestStr := "2023-06-11-14-09.json" olderStr := "2019-01-01-01-01.json" testcases := map[string]struct { @@ -29,21 +29,10 @@ func TestFetchLatestAzureSEVSNPVersion(t *testing.T) { wantErr bool want AzureSEVSNPVersionAPI }{ - "get latest version if older than 2 weeks": { + "get latest version": { fetcherVersions: []string{latestStr, olderStr}, - timeAtTest: now.Add(days(15)), want: latestVersion, }, - "get older version if latest version is not older than minimum age": { - fetcherVersions: []string{"2023-06-11-14-09.json", "2019-01-01-01-01.json"}, - timeAtTest: now.Add(days(7)), - want: olderVersion, - }, - "fail when no version is older minimum age": { - fetcherVersions: []string{"2021-02-21-01-01.json", "2021-02-20-00-00.json"}, - timeAtTest: now.Add(days(2)), - wantErr: true, - }, } for name, tc := range testcases { t.Run(name, func(t *testing.T) { @@ -54,8 +43,8 @@ func TestFetchLatestAzureSEVSNPVersion(t *testing.T) { olderVersion: olderStr, }, } - fetcher := newFetcherWithClientAndVerifier(client, dummyVerifier{}) - res, err := fetcher.FetchAzureSEVSNPVersionLatest(context.Background(), tc.timeAtTest) + fetcher := newFetcherWithClientAndVerifier(client, dummyVerifier{}, constants.CDNRepositoryURL) + res, err := fetcher.FetchAzureSEVSNPVersionLatest(context.Background()) assert := assert.New(t) if tc.wantErr { assert.Error(err) @@ -85,10 +74,6 @@ var olderVersion = AzureSEVSNPVersionAPI{ }, } -func days(days int) time.Duration { - return time.Duration(days*24) * time.Hour -} - type fakeConfigAPIHandler struct { versions []string latestVersion string diff --git a/internal/api/attestationconfigapi/reporter.go b/internal/api/attestationconfigapi/reporter.go new file mode 100644 index 000000000..d9452d7b5 --- /dev/null +++ b/internal/api/attestationconfigapi/reporter.go @@ -0,0 +1,182 @@ +/* +Copyright (c) Edgeless Systems GmbH + +SPDX-License-Identifier: AGPL-3.0-only +*/ + +/* +The reporter contains the logic to determine a latest version for Azure SEVSNP based on cached version values observed on CVM instances. +*/ +package attestationconfigapi + +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/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" + +var reportVersionDir = path.Join(attestationURLPath, variant.AzureSEVSNP{}.String(), cachedVersionsSubDir) + +// 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") + +// UploadAzureSEVSNPVersionLatest 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) UploadAzureSEVSNPVersionLatest(ctx context.Context, inputVersion, + latestAPIVersion AzureSEVSNPVersion, now time.Time, force bool, +) error { + if err := c.cacheAzureSEVSNPVersion(ctx, inputVersion, now); err != nil { + return fmt.Errorf("reporting version: %w", err) + } + if force { + return c.uploadAzureSEVSNPVersion(ctx, inputVersion, now) + } + versionDates, err := c.listCachedVersions(ctx) + if err != nil { + return fmt.Errorf("list reported versions: %w", err) + } + if len(versionDates) < c.cacheWindowSize { + c.s3Client.Logger.Warnf("Skipping version update, found %d, expected %d reported versions.", len(versionDates), c.cacheWindowSize) + return nil + } + minVersion, minDate, err := c.findMinVersion(ctx, versionDates) + if err != nil { + return fmt.Errorf("get minimal version: %w", err) + } + c.s3Client.Logger.Infof("Found minimal version: %+v with date: %s", minVersion, minDate) + shouldUpdateAPI, err := isInputNewerThanOtherVersion(minVersion, latestAPIVersion) + if err != nil { + return ErrNoNewerVersion + } + if !shouldUpdateAPI { + c.s3Client.Logger.Infof("Input version: %+v is not newer than latest API version: %+v", minVersion, latestAPIVersion) + return nil + } + c.s3Client.Logger.Infof("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.uploadAzureSEVSNPVersion(ctx, minVersion, t); err != nil { + return fmt.Errorf("uploading version: %w", err) + } + c.s3Client.Logger.Infof("Successfully uploaded new Azure SEV-SNP version: %+v", minVersion) + return nil +} + +// cacheAzureSEVSNPVersion uploads the latest observed version numbers of the Azure SEVSNP. This version is used to later report the latest version numbers to the API. +func (c Client) cacheAzureSEVSNPVersion(ctx context.Context, version AzureSEVSNPVersion, date time.Time) error { + dateStr := date.Format(VersionFormat) + ".json" + res := putCmd{ + apiObject: reportedAzureSEVSNPVersionAPI{Version: dateStr, AzureSEVSNPVersion: version}, + signer: c.signer, + } + return res.Execute(ctx, c.s3Client) +} + +func (c Client) listCachedVersions(ctx context.Context) ([]string, error) { + list, err := c.s3Client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(c.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 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, versionDates []string) (AzureSEVSNPVersion, string, error) { + var minimalVersion *AzureSEVSNPVersion + 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, reportedAzureSEVSNPVersionAPI{Version: date + ".json"}) + if err != nil { + return AzureSEVSNPVersion{}, "", fmt.Errorf("get object: %w", err) + } + + if minimalVersion == nil { + minimalVersion = &obj.AzureSEVSNPVersion + minimalDate = date + } else { + shouldUpdateMinimal, err := isInputNewerThanOtherVersion(*minimalVersion, obj.AzureSEVSNPVersion) + if err != nil { + continue + } + if shouldUpdateMinimal { + minimalVersion = &obj.AzureSEVSNPVersion + minimalDate = date + } + } + } + return *minimalVersion, minimalDate, nil +} + +// isInputNewerThanOtherVersion compares all version fields and returns true if any input field is newer. +func isInputNewerThanOtherVersion(input, other AzureSEVSNPVersion) (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 +} + +// reportedAzureSEVSNPVersionAPI is the request to get the version information of the specific version in the config api. +type reportedAzureSEVSNPVersionAPI struct { + Version string `json:"-"` + AzureSEVSNPVersion +} + +// 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 +} diff --git a/internal/api/attestationconfigapi/cli/main_test.go b/internal/api/attestationconfigapi/reporter_test.go similarity index 65% rename from internal/api/attestationconfigapi/cli/main_test.go rename to internal/api/attestationconfigapi/reporter_test.go index 3d9c66f38..bfca2f645 100644 --- a/internal/api/attestationconfigapi/cli/main_test.go +++ b/internal/api/attestationconfigapi/reporter_test.go @@ -1,21 +1,18 @@ /* Copyright (c) Edgeless Systems GmbH - SPDX-License-Identifier: AGPL-3.0-only */ - -package main +package attestationconfigapi import ( "testing" - "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" "github.com/stretchr/testify/assert" ) func TestIsInputNewerThanLatestAPI(t *testing.T) { - newTestCfg := func() attestationconfigapi.AzureSEVSNPVersion { - return attestationconfigapi.AzureSEVSNPVersion{ + newTestCfg := func() AzureSEVSNPVersion { + return AzureSEVSNPVersion{ Microcode: 93, TEE: 0, SNP: 6, @@ -24,13 +21,13 @@ func TestIsInputNewerThanLatestAPI(t *testing.T) { } testCases := map[string]struct { - latest attestationconfigapi.AzureSEVSNPVersion - input attestationconfigapi.AzureSEVSNPVersion + latest AzureSEVSNPVersion + input AzureSEVSNPVersion expect bool errMsg string }{ "input is older than latest": { - input: func(c attestationconfigapi.AzureSEVSNPVersion) attestationconfigapi.AzureSEVSNPVersion { + input: func(c AzureSEVSNPVersion) AzureSEVSNPVersion { c.Microcode-- return c }(newTestCfg()), @@ -39,7 +36,7 @@ func TestIsInputNewerThanLatestAPI(t *testing.T) { 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.AzureSEVSNPVersion) attestationconfigapi.AzureSEVSNPVersion { + input: func(c AzureSEVSNPVersion) AzureSEVSNPVersion { c.Microcode++ c.Bootloader-- return c @@ -49,7 +46,7 @@ func TestIsInputNewerThanLatestAPI(t *testing.T) { errMsg: "input Bootloader version: 1 is older than latest API version: 2", }, "input is newer than latest": { - input: func(c attestationconfigapi.AzureSEVSNPVersion) attestationconfigapi.AzureSEVSNPVersion { + input: func(c AzureSEVSNPVersion) AzureSEVSNPVersion { c.TEE++ return c }(newTestCfg()), @@ -64,7 +61,7 @@ func TestIsInputNewerThanLatestAPI(t *testing.T) { } for name, tc := range testCases { t.Run(name, func(t *testing.T) { - isNewer, err := isInputNewerThanLatestAPI(tc.input, tc.latest) + isNewer, err := isInputNewerThanOtherVersion(tc.input, tc.latest) assert := assert.New(t) if tc.errMsg != "" { assert.EqualError(err, tc.errMsg) diff --git a/internal/api/fetcher/BUILD.bazel b/internal/api/fetcher/BUILD.bazel index 3718807da..089b96924 100644 --- a/internal/api/fetcher/BUILD.bazel +++ b/internal/api/fetcher/BUILD.bazel @@ -5,8 +5,5 @@ go_library( srcs = ["fetcher.go"], importpath = "github.com/edgelesssys/constellation/v2/internal/api/fetcher", visibility = ["//:__subpackages__"], - deps = [ - "//internal/constants", - "//internal/sigstore", - ], + deps = ["//internal/sigstore"], ) diff --git a/internal/api/fetcher/fetcher.go b/internal/api/fetcher/fetcher.go index f00181484..c6018743d 100644 --- a/internal/api/fetcher/fetcher.go +++ b/internal/api/fetcher/fetcher.go @@ -23,9 +23,9 @@ import ( "errors" "fmt" "net/http" + "net/url" "strings" - "github.com/edgelesssys/constellation/v2/internal/constants" "github.com/edgelesssys/constellation/v2/internal/sigstore" ) @@ -36,15 +36,17 @@ func NewHTTPClient() HTTPClient { // Fetch fetches the given apiObject from the public Constellation CDN. // Fetch does not require authentication. -func Fetch[T apiObject](ctx context.Context, c HTTPClient, obj T) (T, error) { +func Fetch[T apiObject](ctx context.Context, c HTTPClient, cdnURL string, obj T) (T, error) { if err := obj.ValidateRequest(); err != nil { return *new(T), fmt.Errorf("validating request for %T: %w", obj, err) } - url, err := obj.URL() + urlObj, err := url.Parse(cdnURL) if err != nil { - return *new(T), fmt.Errorf("getting URL for %T: %w", obj, err) + return *new(T), fmt.Errorf("parsing CDN root URL: %w", err) } + urlObj.Path = obj.JSONPath() + url := urlObj.String() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { @@ -80,8 +82,8 @@ func Fetch[T apiObject](ctx context.Context, c HTTPClient, obj T) (T, error) { // The public key used to verify the signature is embedded in the verifier argument. // FetchAndVerify uses a generic to return a new object of type T. // Otherwise the caller would have to cast the interface type to a concrete object, which could fail. -func FetchAndVerify[T apiObject](ctx context.Context, c HTTPClient, obj T, cosignVerifier sigstore.Verifier) (T, error) { - fetchedObj, err := Fetch(ctx, c, obj) +func FetchAndVerify[T apiObject](ctx context.Context, c HTTPClient, cdnURL string, obj T, cosignVerifier sigstore.Verifier) (T, error) { + fetchedObj, err := Fetch(ctx, c, cdnURL, obj) if err != nil { return fetchedObj, fmt.Errorf("fetching object: %w", err) } @@ -89,16 +91,10 @@ func FetchAndVerify[T apiObject](ctx context.Context, c HTTPClient, obj T, cosig if err != nil { return fetchedObj, fmt.Errorf("marshalling object: %w", err) } - - url, err := obj.URL() - if err != nil { - return fetchedObj, fmt.Errorf("getting signed URL: %w", err) - } - signature, err := Fetch(ctx, c, signature{Signed: url}) + signature, err := Fetch(ctx, c, cdnURL, signature{Signed: obj.JSONPath()}) if err != nil { return fetchedObj, fmt.Errorf("fetching signature: %w", err) } - err = cosignVerifier.VerifySignature(marshalledObj, signature.Signature) if err != nil { return fetchedObj, fmt.Errorf("verifying signature: %w", err) @@ -127,28 +123,24 @@ type HTTPClient interface { type apiObject interface { ValidateRequest() error Validate() error - URL() (string, error) + JSONPath() string } // signature manages the signature of a object saved at location 'Signed'. type signature struct { // Signed is the object that is signed. - Signed string `json:"signed"` + Signed string `json:"-"` // Signature is the signature of `Signed`. Signature []byte `json:"signature"` } // URL returns the URL for the request to the config api. -func (s signature) URL() (string, error) { - return s.Signed + ".sig", nil +func (s signature) JSONPath() string { + return s.Signed + ".sig" } // ValidateRequest validates the request. func (s signature) ValidateRequest() error { - if !strings.HasPrefix(s.Signed, constants.CDNRepositoryURL) { - return errors.New("signed object missing CDN URL prefix") - } - if !strings.HasSuffix(s.Signed, ".json") { return errors.New("signed object missing .json suffix") } diff --git a/internal/api/versionsapi/fetcher.go b/internal/api/versionsapi/fetcher.go index d67e540f4..e17d7a376 100644 --- a/internal/api/versionsapi/fetcher.go +++ b/internal/api/versionsapi/fetcher.go @@ -10,34 +10,36 @@ import ( "context" "github.com/edgelesssys/constellation/v2/internal/api/fetcher" + "github.com/edgelesssys/constellation/v2/internal/constants" ) // Fetcher fetches version API resources without authentication. type Fetcher struct { fetcher.HTTPClient + cdnURL string } // NewFetcher returns a new Fetcher. func NewFetcher() *Fetcher { - return &Fetcher{fetcher.NewHTTPClient()} + return &Fetcher{fetcher.NewHTTPClient(), constants.CDNRepositoryURL} } // FetchVersionList fetches the given version list from the versions API. func (f *Fetcher) FetchVersionList(ctx context.Context, list List) (List, error) { - return fetcher.Fetch(ctx, f.HTTPClient, list) + return fetcher.Fetch(ctx, f.HTTPClient, f.cdnURL, list) } // FetchVersionLatest fetches the latest version from the versions API. func (f *Fetcher) FetchVersionLatest(ctx context.Context, latest Latest) (Latest, error) { - return fetcher.Fetch(ctx, f.HTTPClient, latest) + return fetcher.Fetch(ctx, f.HTTPClient, f.cdnURL, latest) } // FetchImageInfo fetches the given image info from the versions API. func (f *Fetcher) FetchImageInfo(ctx context.Context, imageInfo ImageInfo) (ImageInfo, error) { - return fetcher.Fetch(ctx, f.HTTPClient, imageInfo) + return fetcher.Fetch(ctx, f.HTTPClient, f.cdnURL, imageInfo) } // FetchCLIInfo fetches the given cli info from the versions API. func (f *Fetcher) FetchCLIInfo(ctx context.Context, cliInfo CLIInfo) (CLIInfo, error) { - return fetcher.Fetch(ctx, f.HTTPClient, cliInfo) + return fetcher.Fetch(ctx, f.HTTPClient, f.cdnURL, cliInfo) } diff --git a/internal/api/versionsapi/fetcher_test.go b/internal/api/versionsapi/fetcher_test.go index bf5bfd882..810400af9 100644 --- a/internal/api/versionsapi/fetcher_test.go +++ b/internal/api/versionsapi/fetcher_test.go @@ -14,6 +14,7 @@ import ( "net/http" "testing" + "github.com/edgelesssys/constellation/v2/internal/constants" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/goleak" @@ -189,7 +190,7 @@ func TestFetchVersionList(t *testing.T) { return tc.serverResp }) - fetcher := Fetcher{client} + fetcher := Fetcher{client, constants.CDNRepositoryURL} list, err := fetcher.FetchVersionList(context.Background(), tc.list) diff --git a/internal/config/azure.go b/internal/config/azure.go index e7a39a864..473dcd5cf 100644 --- a/internal/config/azure.go +++ b/internal/config/azure.go @@ -9,7 +9,6 @@ import ( "bytes" "context" "fmt" - "time" "github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi" "github.com/edgelesssys/constellation/v2/internal/attestation/idkeydigest" @@ -70,8 +69,8 @@ func (c AzureSEVSNP) EqualTo(old AttestationCfg) (bool, error) { } // FetchAndSetLatestVersionNumbers fetches the latest version numbers from the configapi and sets them. -func (c *AzureSEVSNP) FetchAndSetLatestVersionNumbers(ctx context.Context, fetcher attestationconfigapi.Fetcher, now time.Time) error { - versions, err := fetcher.FetchAzureSEVSNPVersionLatest(ctx, now) +func (c *AzureSEVSNP) FetchAndSetLatestVersionNumbers(ctx context.Context, fetcher attestationconfigapi.Fetcher) error { + versions, err := fetcher.FetchAzureSEVSNPVersionLatest(ctx) if err != nil { return err } diff --git a/internal/config/config.go b/internal/config/config.go index 3c9995bfb..33a00323a 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,7 +26,6 @@ import ( "os" "reflect" "strings" - "time" "github.com/go-playground/locales/en" ut "github.com/go-playground/universal-translator" @@ -456,7 +455,7 @@ func New(fileHandler file.Handler, name string, fetcher attestationconfigapi.Fet } if azure := c.Attestation.AzureSEVSNP; azure != nil { - if err := azure.FetchAndSetLatestVersionNumbers(context.Background(), fetcher, time.Now()); err != nil { + if err := azure.FetchAndSetLatestVersionNumbers(context.Background(), fetcher); err != nil { return c, err } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index ca4e51056..5def7e975 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -11,7 +11,6 @@ import ( "errors" "reflect" "testing" - "time" "github.com/go-playground/locales/en" ut "github.com/go-playground/universal-translator" @@ -1093,7 +1092,7 @@ func (f stubAttestationFetcher) FetchAzureSEVSNPVersion(_ context.Context, _ att }, nil } -func (f stubAttestationFetcher) FetchAzureSEVSNPVersionLatest(_ context.Context, _ time.Time) (attestationconfigapi.AzureSEVSNPVersionAPI, error) { +func (f stubAttestationFetcher) FetchAzureSEVSNPVersionLatest(_ context.Context) (attestationconfigapi.AzureSEVSNPVersionAPI, error) { return attestationconfigapi.AzureSEVSNPVersionAPI{ AzureSEVSNPVersion: testCfg, }, nil