Improve measurements verification with Rekor (#206)

Fetched measurements are now verified using Rekor in addition to a signature check.
Signed-off-by: Fabian Kammel <fk@edgeless.systems>
This commit is contained in:
Fabian Kammel 2022-10-11 13:57:52 +02:00 committed by GitHub
parent 1c29638421
commit 57b8efd1ec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 1320 additions and 322 deletions

View file

@ -16,6 +16,7 @@ import (
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/sigstore"
"github.com/spf13/afero"
"github.com/spf13/cobra"
)
@ -42,10 +43,14 @@ type fetchMeasurementsFlags struct {
func runConfigFetchMeasurements(cmd *cobra.Command, args []string) error {
fileHandler := file.NewHandler(afero.NewOsFs())
return configFetchMeasurements(cmd, fileHandler, http.DefaultClient)
rekor, err := sigstore.NewRekor()
if err != nil {
return fmt.Errorf("constructing Rekor client: %w", err)
}
return configFetchMeasurements(cmd, rekor, fileHandler, http.DefaultClient)
}
func configFetchMeasurements(cmd *cobra.Command, fileHandler file.Handler, client *http.Client) error {
func configFetchMeasurements(cmd *cobra.Command, verifier rekorVerifier, fileHandler file.Handler, client *http.Client) error {
flags, err := parseFetchMeasurementsFlags(cmd)
if err != nil {
return err
@ -67,10 +72,16 @@ func configFetchMeasurements(cmd *cobra.Command, fileHandler file.Handler, clien
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var fetchedMeasurements config.Measurements
if err := fetchedMeasurements.FetchAndVerify(ctx, client, flags.measurementsURL, flags.signatureURL, []byte(constants.CosignPublicKey)); err != nil {
hash, err := fetchedMeasurements.FetchAndVerify(ctx, client, flags.measurementsURL, flags.signatureURL, []byte(constants.CosignPublicKey))
if err != nil {
return err
}
if err := verifyWithRekor(cmd.Context(), verifier, hash); err != nil {
cmd.Printf("Ignoring Rekor related error: %v\n", err)
cmd.Println("Make sure the downloaded measurements are trustworthy!")
}
conf.UpdateMeasurements(fetchedMeasurements)
if err := fileHandler.WriteYAML(flags.config, conf, file.OptOverwrite); err != nil {
return err

View file

@ -8,6 +8,7 @@ package cmd
import (
"bytes"
"errors"
"io"
"net/http"
"net/url"
@ -69,7 +70,7 @@ func TestParseFetchMeasurementsFlags(t *testing.T) {
require := require.New(t)
cmd := newConfigFetchMeasurementsCmd()
cmd.Flags().String("config", constants.ConfigFilename, "") // register persisten flag manually
cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually
if tc.urlFlag != "" {
require.NoError(cmd.Flags().Set("url", tc.urlFlag))
@ -149,9 +150,6 @@ func newTestClient(fn roundTripFunc) *http.Client {
}
func TestConfigFetchMeasurements(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
measurements := `1: fPRxd3lV3uybnSVhcBmM6XLzcvMitXW78G0RRuQxYGc=
2: PUWM/lXMA+ofRD8VYr7sjfUcdeFKn8+acjShPxmOeWk=
3: PUWM/lXMA+ofRD8VYr7sjfUcdeFKn8+acjShPxmOeWk=
@ -163,17 +161,6 @@ func TestConfigFetchMeasurements(t *testing.T) {
`
signature := "MEUCIFdJ5dH6HDywxQWTUh9Bw77wMrq0mNCUjMQGYP+6QsVmAiEAmazj/L7rFGA4/Gz8y+kI5h5E5cDgc3brihvXBKF6qZA="
cmd := newConfigFetchMeasurementsCmd()
cmd.Flags().String("config", constants.ConfigFilename, "") // register persisten flag manually
fileHandler := file.NewHandler(afero.NewMemMapFs())
gcpConfig := config.Default()
gcpConfig.RemoveProviderExcept(cloudprovider.GCP)
gcpConfig.Provider.GCP.Image = "projects/constellation-images/global/images/constellation-coreos-1658216163"
err := fileHandler.WriteYAML(constants.ConfigFilename, gcpConfig, file.OptMkdirAll)
require.NoError(err)
client := newTestClient(func(req *http.Request) *http.Response {
if req.URL.String() == "https://public-edgeless-constellation.s3.us-east-2.amazonaws.com/projects/constellation-images/global/images/constellation-coreos-1658216163/measurements.yaml" {
return &http.Response{
@ -196,5 +183,43 @@ func TestConfigFetchMeasurements(t *testing.T) {
}
})
assert.NoError(configFetchMeasurements(cmd, fileHandler, client))
testCases := map[string]struct {
verifier rekorVerifier
}{
"success": {
verifier: singleUUIDVerifier(),
},
"failing search should not result in error": {
verifier: &stubRekorVerifier{
SearchByHashUUIDs: []string{},
SearchByHashError: errors.New("some error"),
},
},
"failing verify should not result in error": {
verifier: &stubRekorVerifier{
SearchByHashUUIDs: []string{"11111111111111111111111111111111111111111111111111111111111111111111111111111111"},
VerifyEntryError: errors.New("some error"),
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
cmd := newConfigFetchMeasurementsCmd()
cmd.Flags().String("config", constants.ConfigFilename, "") // register persistent flag manually
fileHandler := file.NewHandler(afero.NewMemMapFs())
gcpConfig := config.Default()
gcpConfig.RemoveProviderExcept(cloudprovider.GCP)
gcpConfig.Provider.GCP.Image = "projects/constellation-images/global/images/constellation-coreos-1658216163"
err := fileHandler.WriteYAML(constants.ConfigFilename, gcpConfig, file.OptMkdirAll)
require.NoError(err)
assert.NoError(configFetchMeasurements(cmd, tc.verifier, fileHandler, client))
})
}
}

View file

@ -21,6 +21,7 @@ import (
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/sigstore"
"github.com/manifoldco/promptui"
"github.com/spf13/afero"
"github.com/spf13/cobra"
@ -60,13 +61,17 @@ func runUpgradePlan(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
rekor, err := sigstore.NewRekor()
if err != nil {
return fmt.Errorf("constructing Rekor client: %w", err)
}
return upgradePlan(cmd, planner, fileHandler, http.DefaultClient, flags)
return upgradePlan(cmd, planner, fileHandler, http.DefaultClient, rekor, flags)
}
// upgradePlan plans an upgrade of a Constellation cluster.
func upgradePlan(cmd *cobra.Command, planner upgradePlanner,
fileHandler file.Handler, client *http.Client, flags upgradePlanFlags,
fileHandler file.Handler, client *http.Client, rekor rekorVerifier, flags upgradePlanFlags,
) error {
config, err := config.FromFile(fileHandler, flags.configPath)
if err != nil {
@ -93,7 +98,7 @@ func upgradePlan(cmd *cobra.Command, planner upgradePlanner,
}
// get expected measurements for each image
if err := getCompatibleImageMeasurements(cmd.Context(), client, []byte(flags.cosignPubKey), compatibleImages); err != nil {
if err := getCompatibleImageMeasurements(cmd.Context(), client, rekor, []byte(flags.cosignPubKey), compatibleImages); err != nil {
return fmt.Errorf("fetching measurements for compatible images: %w", err)
}
@ -174,7 +179,7 @@ func getCompatibleImages(csp cloudprovider.Provider, currentVersion string, imag
}
// getCompatibleImageMeasurements retrieves the expected measurements for each image.
func getCompatibleImageMeasurements(ctx context.Context, client *http.Client, pubK []byte, images map[string]config.UpgradeConfig) error {
func getCompatibleImageMeasurements(ctx context.Context, client *http.Client, rekor rekorVerifier, pubK []byte, images map[string]config.UpgradeConfig) error {
for idx, img := range images {
measurementsURL, err := url.Parse(constants.S3PublicBucket + img.Image + "/measurements.yaml")
if err != nil {
@ -186,9 +191,16 @@ func getCompatibleImageMeasurements(ctx context.Context, client *http.Client, pu
return err
}
if err := img.Measurements.FetchAndVerify(ctx, client, measurementsURL, signatureURL, pubK); err != nil {
hash, err := img.Measurements.FetchAndVerify(ctx, client, measurementsURL, signatureURL, pubK)
if err != nil {
return err
}
if err = verifyWithRekor(ctx, rekor, hash); err != nil {
fmt.Printf("Warning: Unable to verify '%s' in Rekor.\n", hash)
fmt.Printf("Make sure measurements are correct.\n")
}
images[idx] = img
}

View file

@ -271,7 +271,7 @@ func TestGetCompatibleImageMeasurements(t *testing.T) {
pubK := []byte("-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----")
err := getCompatibleImageMeasurements(context.Background(), client, pubK, testImages)
err := getCompatibleImageMeasurements(context.Background(), client, singleUUIDVerifier(), pubK, testImages)
assert.NoError(err)
for _, image := range testImages {
@ -295,6 +295,7 @@ func TestUpgradePlan(t *testing.T) {
planner stubUpgradePlanner
flags upgradePlanFlags
csp cloudprovider.Provider
verifier rekorVerifier
imageFetchStatus int
measurementsFetchStatus int
wantUpgrade bool
@ -312,6 +313,7 @@ func TestUpgradePlan(t *testing.T) {
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantUpgrade: false,
},
"upgrades gcp": {
@ -326,6 +328,7 @@ func TestUpgradePlan(t *testing.T) {
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantUpgrade: true,
},
"upgrades azure": {
@ -340,6 +343,7 @@ func TestUpgradePlan(t *testing.T) {
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.Azure,
verifier: singleUUIDVerifier(),
wantUpgrade: true,
},
"upgrade to stdout": {
@ -354,6 +358,7 @@ func TestUpgradePlan(t *testing.T) {
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantUpgrade: true,
},
"current image not valid": {
@ -367,8 +372,9 @@ func TestUpgradePlan(t *testing.T) {
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
wantErr: true,
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantErr: true,
},
"image fetch error": {
planner: stubUpgradePlanner{
@ -381,8 +387,9 @@ func TestUpgradePlan(t *testing.T) {
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
wantErr: true,
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantErr: true,
},
"measurements fetch error": {
planner: stubUpgradePlanner{
@ -395,8 +402,45 @@ func TestUpgradePlan(t *testing.T) {
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
wantErr: true,
csp: cloudprovider.GCP,
verifier: singleUUIDVerifier(),
wantErr: true,
},
"failing search should not result in error": {
planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v1-0-0",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: &stubRekorVerifier{
SearchByHashUUIDs: []string{},
SearchByHashError: errors.New("some error"),
},
wantUpgrade: true,
},
"failing verify should not result in error": {
planner: stubUpgradePlanner{
image: "projects/constellation-images/global/images/constellation-v1-0-0",
},
imageFetchStatus: http.StatusOK,
measurementsFetchStatus: http.StatusOK,
flags: upgradePlanFlags{
configPath: constants.ConfigFilename,
filePath: "upgrade-plan.yaml",
cosignPubKey: "-----BEGIN PUBLIC KEY-----\nMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEUs5fDUIz9aiwrfr8BK4VjN7jE6sl\ngz7UuXsOin8+dB0SGrbNHy7TJToa2fAiIKPVLTOfvY75DqRAtffhO1fpBA==\n-----END PUBLIC KEY-----",
},
csp: cloudprovider.GCP,
verifier: &stubRekorVerifier{
SearchByHashUUIDs: []string{"11111111111111111111111111111111111111111111111111111111111111111111111111111111"},
VerifyEntryError: errors.New("some error"),
},
wantUpgrade: true,
},
}
@ -445,7 +489,7 @@ func TestUpgradePlan(t *testing.T) {
}
})
err := upgradePlan(cmd, tc.planner, fileHandler, client, tc.flags)
err := upgradePlan(cmd, tc.planner, fileHandler, client, tc.verifier, tc.flags)
if tc.wantErr {
assert.Error(err)
return

View file

@ -0,0 +1,42 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"context"
"encoding/base64"
"fmt"
"github.com/edgelesssys/constellation/v2/internal/constants"
)
type rekorVerifier interface {
SearchByHash(context.Context, string) ([]string, error)
VerifyEntry(context.Context, string, string) error
}
func verifyWithRekor(ctx context.Context, verifier rekorVerifier, hash string) error {
uuids, err := verifier.SearchByHash(ctx, hash)
if err != nil {
return fmt.Errorf("searching Rekor for hash: %w", err)
}
if len(uuids) == 0 {
return fmt.Errorf("no matching entries in Rekor")
}
// We expect the first entry in Rekor to be our original entry.
// SHA256 should ensure there is no entry with the same hash.
// Any subsequent hashes are treated as potential attacks and are ignored.
// Attacks on Rekor will be monitored from other backend services.
artifactUUID := uuids[0]
return verifier.VerifyEntry(
ctx, artifactUUID,
base64.StdEncoding.EncodeToString([]byte(constants.CosignPublicKey)),
)
}

View file

@ -0,0 +1,36 @@
/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import "context"
// singleUUIDVerifier constructs a RekorVerifier that returns a single UUID and no errors,
// and should work for most tests on the happy path.
func singleUUIDVerifier() *stubRekorVerifier {
return &stubRekorVerifier{
SearchByHashUUIDs: []string{"11111111111111111111111111111111111111111111111111111111111111111111111111111111"},
SearchByHashError: nil,
VerifyEntryError: nil,
}
}
// SubRekorVerifier is a stub for RekorVerifier.
type stubRekorVerifier struct {
SearchByHashUUIDs []string
SearchByHashError error
VerifyEntryError error
}
// SearchByHash returns the exported fields SearchByHashUUIDs, SearchByHashError.
func (v *stubRekorVerifier) SearchByHash(context.Context, string) ([]string, error) {
return v.SearchByHashUUIDs, v.SearchByHashError
}
// VerifyEntry returns the exported field VerifyEntryError.
func (v *stubRekorVerifier) VerifyEntry(context.Context, string, string) error {
return v.VerifyEntryError
}