cli: fix Azure SEV-SNP latest version logic (#2343)

This commit is contained in:
Adrian Stobbe 2023-09-25 11:53:02 +02:00 committed by GitHub
parent 2776e40df7
commit 118f789c2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
26 changed files with 547 additions and 245 deletions

View file

@ -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",
],
)

View file

@ -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
}

View file

@ -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",
],

View file

@ -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
}

View file

@ -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

View file

@ -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)

View file

@ -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, &notFoundErr) {
return nil, nil
}
return nil, err
}
return versions, nil

View file

@ -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")
}

View file

@ -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

View file

@ -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
}

View file

@ -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)