constellation/cli/internal/cmd/verify_test.go
Daniel Weiße e350ca0f57
attestation: add Azure TDX attestation (#2827)
* Implement Azure TDX attestation primitives
* Add default measurements and claims for Azure TDX
* Enable Constellation on Azure TDX

---------

Signed-off-by: Daniel Weiße <dw@edgeless.systems>
2024-01-24 15:10:15 +01:00

724 lines
21 KiB
Go

/*
Copyright (c) Edgeless Systems GmbH
SPDX-License-Identifier: AGPL-3.0-only
*/
package cmd
import (
"bytes"
"context"
"crypto/sha256"
"crypto/sha512"
"encoding/base64"
"encoding/json"
"errors"
"net"
"strconv"
"strings"
"testing"
"github.com/edgelesssys/constellation/v2/internal/atls"
"github.com/edgelesssys/constellation/v2/internal/attestation/measurements"
"github.com/edgelesssys/constellation/v2/internal/attestation/variant"
"github.com/edgelesssys/constellation/v2/internal/cloud/cloudprovider"
"github.com/edgelesssys/constellation/v2/internal/config"
"github.com/edgelesssys/constellation/v2/internal/constants"
"github.com/edgelesssys/constellation/v2/internal/constellation/state"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/grpc/dialer"
"github.com/edgelesssys/constellation/v2/internal/grpc/testdialer"
"github.com/edgelesssys/constellation/v2/internal/logger"
"github.com/edgelesssys/constellation/v2/verify/verifyproto"
tpmProto "github.com/google/go-tpm-tools/proto/tpm"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
rpcStatus "google.golang.org/grpc/status"
)
func TestVerify(t *testing.T) {
zeroBase64 := base64.StdEncoding.EncodeToString([]byte("00000000000000000000000000000000"))
someErr := errors.New("failed")
testCases := map[string]struct {
provider cloudprovider.Provider
protoClient *stubVerifyClient
formatter *stubAttDocFormatter
nodeEndpointFlag string
clusterIDFlag string
stateFile *state.State
wantEndpoint string
skipConfigCreation bool
wantErr bool
}{
"gcp": {
provider: cloudprovider.GCP,
nodeEndpointFlag: "192.0.2.1:1234",
clusterIDFlag: zeroBase64,
protoClient: &stubVerifyClient{},
stateFile: defaultStateFile(cloudprovider.GCP),
wantEndpoint: "192.0.2.1:1234",
formatter: &stubAttDocFormatter{},
},
"azure": {
provider: cloudprovider.Azure,
nodeEndpointFlag: "192.0.2.1:1234",
clusterIDFlag: zeroBase64,
protoClient: &stubVerifyClient{},
stateFile: defaultStateFile(cloudprovider.Azure),
wantEndpoint: "192.0.2.1:1234",
formatter: &stubAttDocFormatter{},
},
"default port": {
provider: cloudprovider.GCP,
nodeEndpointFlag: "192.0.2.1",
clusterIDFlag: zeroBase64,
protoClient: &stubVerifyClient{},
stateFile: defaultStateFile(cloudprovider.GCP),
wantEndpoint: "192.0.2.1:" + strconv.Itoa(constants.VerifyServiceNodePortGRPC),
formatter: &stubAttDocFormatter{},
},
"endpoint not set": {
provider: cloudprovider.GCP,
clusterIDFlag: zeroBase64,
protoClient: &stubVerifyClient{},
stateFile: func() *state.State {
s := defaultStateFile(cloudprovider.GCP)
s.Infrastructure.ClusterEndpoint = ""
return s
}(),
formatter: &stubAttDocFormatter{},
wantErr: true,
},
"endpoint from state file": {
provider: cloudprovider.GCP,
clusterIDFlag: zeroBase64,
protoClient: &stubVerifyClient{},
stateFile: func() *state.State {
s := defaultStateFile(cloudprovider.GCP)
s.Infrastructure.ClusterEndpoint = "192.0.2.1"
return s
}(),
wantEndpoint: "192.0.2.1:" + strconv.Itoa(constants.VerifyServiceNodePortGRPC),
formatter: &stubAttDocFormatter{},
},
"override endpoint from details file": {
provider: cloudprovider.GCP,
nodeEndpointFlag: "192.0.2.2:1234",
clusterIDFlag: zeroBase64,
protoClient: &stubVerifyClient{},
stateFile: func() *state.State {
s := defaultStateFile(cloudprovider.GCP)
s.Infrastructure.ClusterEndpoint = "192.0.2.1"
return s
}(),
wantEndpoint: "192.0.2.2:1234",
formatter: &stubAttDocFormatter{},
},
"invalid endpoint": {
provider: cloudprovider.GCP,
nodeEndpointFlag: ":::::",
clusterIDFlag: zeroBase64,
protoClient: &stubVerifyClient{},
stateFile: defaultStateFile(cloudprovider.GCP),
formatter: &stubAttDocFormatter{},
wantErr: true,
},
"neither owner id nor cluster id set": {
provider: cloudprovider.GCP,
nodeEndpointFlag: "192.0.2.1:1234",
stateFile: func() *state.State {
s := defaultStateFile(cloudprovider.GCP)
s.ClusterValues.OwnerID = ""
s.ClusterValues.ClusterID = ""
return s
}(),
formatter: &stubAttDocFormatter{},
protoClient: &stubVerifyClient{},
wantErr: true,
},
"use owner id from state file": {
provider: cloudprovider.GCP,
nodeEndpointFlag: "192.0.2.1:1234",
protoClient: &stubVerifyClient{},
stateFile: func() *state.State {
s := defaultStateFile(cloudprovider.GCP)
s.ClusterValues.OwnerID = zeroBase64
return s
}(),
wantEndpoint: "192.0.2.1:1234",
formatter: &stubAttDocFormatter{},
},
"config file not existing": {
provider: cloudprovider.GCP,
clusterIDFlag: zeroBase64,
nodeEndpointFlag: "192.0.2.1:1234",
stateFile: defaultStateFile(cloudprovider.GCP),
formatter: &stubAttDocFormatter{},
skipConfigCreation: true,
wantErr: true,
},
"error protoClient GetState": {
provider: cloudprovider.Azure,
nodeEndpointFlag: "192.0.2.1:1234",
clusterIDFlag: zeroBase64,
protoClient: &stubVerifyClient{verifyErr: rpcStatus.Error(codes.Internal, "failed")},
stateFile: defaultStateFile(cloudprovider.Azure),
formatter: &stubAttDocFormatter{},
wantErr: true,
},
"error protoClient GetState not rpc": {
provider: cloudprovider.Azure,
nodeEndpointFlag: "192.0.2.1:1234",
clusterIDFlag: zeroBase64,
protoClient: &stubVerifyClient{verifyErr: someErr},
stateFile: defaultStateFile(cloudprovider.Azure),
formatter: &stubAttDocFormatter{},
wantErr: true,
},
"format error": {
provider: cloudprovider.Azure,
nodeEndpointFlag: "192.0.2.1:1234",
clusterIDFlag: zeroBase64,
protoClient: &stubVerifyClient{},
stateFile: defaultStateFile(cloudprovider.Azure),
wantEndpoint: "192.0.2.1:1234",
formatter: &stubAttDocFormatter{formatErr: someErr},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
cmd := NewVerifyCmd()
out := &bytes.Buffer{}
cmd.SetErr(out)
fileHandler := file.NewHandler(afero.NewMemMapFs())
if !tc.skipConfigCreation {
cfg := defaultConfigWithExpectedMeasurements(t, config.Default(), tc.provider)
require.NoError(fileHandler.WriteYAML(constants.ConfigFilename, cfg))
}
require.NoError(tc.stateFile.WriteToFile(fileHandler, constants.StateFilename))
v := &verifyCmd{
fileHandler: fileHandler,
log: logger.NewTest(t),
flags: verifyFlags{
clusterID: tc.clusterIDFlag,
endpoint: tc.nodeEndpointFlag,
},
}
formatterFac := func(_ string, _ variant.Variant, _ debugLog) (attestationDocFormatter, error) {
return tc.formatter, nil
}
err := v.verify(cmd, tc.protoClient, formatterFac, stubAttestationFetcher{})
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
assert.Contains(out.String(), "OK")
assert.Equal(tc.wantEndpoint, tc.protoClient.endpoint)
}
})
}
}
type stubAttDocFormatter struct {
formatErr error
}
func (f *stubAttDocFormatter) format(_ context.Context, _ string, _ bool, _ config.AttestationCfg) (string, error) {
return "", f.formatErr
}
func TestFormat(t *testing.T) {
formatter := func() *defaultAttestationDocFormatter {
return &defaultAttestationDocFormatter{
log: logger.NewTest(t),
}
}
testCases := map[string]struct {
formatter *defaultAttestationDocFormatter
doc string
wantErr bool
}{
"invalid doc": {
formatter: formatter(),
doc: "invalid",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
_, err := tc.formatter.format(context.Background(), tc.doc, false, nil)
if tc.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestVerifyClient(t *testing.T) {
testCases := map[string]struct {
attestationDoc atls.FakeAttestationDoc
nonce []byte
attestationErr error
wantErr bool
}{
"success": {
attestationDoc: atls.FakeAttestationDoc{
UserData: []byte(constants.ConstellationVerifyServiceUserData),
Nonce: []byte("nonce"),
},
nonce: []byte("nonce"),
},
"attestation error": {
attestationDoc: atls.FakeAttestationDoc{
UserData: []byte(constants.ConstellationVerifyServiceUserData),
Nonce: []byte("nonce"),
},
nonce: []byte("nonce"),
attestationErr: errors.New("error"),
wantErr: true,
},
"user data does not match": {
attestationDoc: atls.FakeAttestationDoc{
UserData: []byte("wrong user data"),
Nonce: []byte("nonce"),
},
nonce: []byte("nonce"),
wantErr: true,
},
"nonce does not match": {
attestationDoc: atls.FakeAttestationDoc{
UserData: []byte(constants.ConstellationVerifyServiceUserData),
Nonce: []byte("wrong nonce"),
},
nonce: []byte("nonce"),
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
attestation, err := json.Marshal(tc.attestationDoc)
require.NoError(err)
verifyAPI := &stubVerifyAPI{
attestation: &verifyproto.GetAttestationResponse{Attestation: attestation},
attestationErr: tc.attestationErr,
}
netDialer := testdialer.NewBufconnDialer()
dialer := dialer.New(nil, nil, netDialer)
verifyServer := grpc.NewServer()
verifyproto.RegisterAPIServer(verifyServer, verifyAPI)
addr := net.JoinHostPort("192.0.2.1", strconv.Itoa(constants.VerifyServiceNodePortGRPC))
listener := netDialer.GetListener(addr)
go verifyServer.Serve(listener)
defer verifyServer.GracefulStop()
verifier := &constellationVerifier{dialer: dialer, log: logger.NewTest(t)}
request := &verifyproto.GetAttestationRequest{
Nonce: tc.nonce,
}
_, err = verifier.Verify(context.Background(), addr, request, atls.NewFakeValidator(variant.Dummy{}))
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
}
})
}
}
type stubVerifyClient struct {
verifyErr error
endpoint string
}
func (c *stubVerifyClient) Verify(_ context.Context, endpoint string, _ *verifyproto.GetAttestationRequest, _ atls.Validator) (string, error) {
c.endpoint = endpoint
return "", c.verifyErr
}
type stubVerifyAPI struct {
attestation *verifyproto.GetAttestationResponse
attestationErr error
verifyproto.UnimplementedAPIServer
}
func (a stubVerifyAPI) GetAttestation(context.Context, *verifyproto.GetAttestationRequest) (*verifyproto.GetAttestationResponse, error) {
return a.attestation, a.attestationErr
}
func TestAddPortIfMissing(t *testing.T) {
testCases := map[string]struct {
endpoint string
defaultPort int
wantResult string
wantErr bool
}{
"ip and port": {
endpoint: "192.0.2.1:2",
defaultPort: 3,
wantResult: "192.0.2.1:2",
},
"hostname and port": {
endpoint: "foo:2",
defaultPort: 3,
wantResult: "foo:2",
},
"ip": {
endpoint: "192.0.2.1",
defaultPort: 3,
wantResult: "192.0.2.1:3",
},
"hostname": {
endpoint: "foo",
defaultPort: 3,
wantResult: "foo:3",
},
"empty endpoint": {
endpoint: "",
defaultPort: 3,
wantErr: true,
},
"invalid endpoint": {
endpoint: "foo:2:2",
defaultPort: 3,
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
require := require.New(t)
res, err := addPortIfMissing(tc.endpoint, tc.defaultPort)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(err)
assert.Equal(tc.wantResult, res)
})
}
}
func TestParseQuotes(t *testing.T) {
testCases := map[string]struct {
quotes []*tpmProto.Quote
expectedPCRs measurements.M
wantOutput string
wantErr bool
}{
"parse quotes in order": {
quotes: []*tpmProto.Quote{
{
Pcrs: &tpmProto.PCRs{
Hash: tpmProto.HashAlgo_SHA256,
Pcrs: map[uint32][]byte{
0: {0x00},
1: {0x01},
},
},
},
},
expectedPCRs: measurements.M{
0: measurements.WithAllBytes(0x00, measurements.Enforce, 1),
1: measurements.WithAllBytes(0x01, measurements.WarnOnly, 1),
},
wantOutput: "\tQuote:\n\t\tPCR 0 (Strict: true):\n\t\t\tExpected:\t00\n\t\t\tActual:\t\t00\n\t\tPCR 1 (Strict: false):\n\t\t\tExpected:\t01\n\t\t\tActual:\t\t01\n",
},
"additional quotes are skipped": {
quotes: []*tpmProto.Quote{
{
Pcrs: &tpmProto.PCRs{
Hash: tpmProto.HashAlgo_SHA256,
Pcrs: map[uint32][]byte{
0: {0x00},
1: {0x01},
2: {0x02},
3: {0x03},
},
},
},
},
expectedPCRs: measurements.M{
0: measurements.WithAllBytes(0x00, measurements.Enforce, 1),
1: measurements.WithAllBytes(0x01, measurements.WarnOnly, 1),
},
wantOutput: "\tQuote:\n\t\tPCR 0 (Strict: true):\n\t\t\tExpected:\t00\n\t\t\tActual:\t\t00\n\t\tPCR 1 (Strict: false):\n\t\t\tExpected:\t01\n\t\t\tActual:\t\t01\n",
},
"missing quotes error": {
quotes: []*tpmProto.Quote{
{
Pcrs: &tpmProto.PCRs{
Hash: tpmProto.HashAlgo_SHA256,
Pcrs: map[uint32][]byte{
0: {0x00},
},
},
},
},
expectedPCRs: measurements.M{
0: measurements.WithAllBytes(0x00, measurements.Enforce, 1),
1: measurements.WithAllBytes(0x01, measurements.WarnOnly, 1),
},
wantErr: true,
},
"no quotes error": {
quotes: []*tpmProto.Quote{},
expectedPCRs: measurements.M{
0: measurements.WithAllBytes(0x00, measurements.Enforce, 1),
1: measurements.WithAllBytes(0x01, measurements.WarnOnly, 1),
},
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
b := &strings.Builder{}
parser := &defaultAttestationDocFormatter{}
err := parser.parseQuotes(b, tc.quotes, tc.expectedPCRs)
if tc.wantErr {
assert.Error(err)
} else {
assert.NoError(err)
assert.Equal(tc.wantOutput, b.String())
}
})
}
}
func TestValidatorUpdateInitPCRs(t *testing.T) {
zero := measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength)
one := measurements.WithAllBytes(0x11, measurements.WarnOnly, measurements.PCRMeasurementLength)
one64 := base64.StdEncoding.EncodeToString(one.Expected[:])
oneHash := sha256.Sum256(one.Expected[:])
pcrZeroUpdatedOne := sha256.Sum256(append(zero.Expected[:], oneHash[:]...))
newTestPCRs := func() measurements.M {
return measurements.M{
0: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
1: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
2: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
3: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
4: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
5: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
6: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
7: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
8: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
9: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
10: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
11: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
12: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
13: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
14: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
15: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
16: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
17: measurements.WithAllBytes(0x11, measurements.WarnOnly, measurements.PCRMeasurementLength),
18: measurements.WithAllBytes(0x11, measurements.WarnOnly, measurements.PCRMeasurementLength),
19: measurements.WithAllBytes(0x11, measurements.WarnOnly, measurements.PCRMeasurementLength),
20: measurements.WithAllBytes(0x11, measurements.WarnOnly, measurements.PCRMeasurementLength),
21: measurements.WithAllBytes(0x11, measurements.WarnOnly, measurements.PCRMeasurementLength),
22: measurements.WithAllBytes(0x11, measurements.WarnOnly, measurements.PCRMeasurementLength),
23: measurements.WithAllBytes(0x00, measurements.WarnOnly, measurements.PCRMeasurementLength),
}
}
testCases := map[string]struct {
config config.AttestationCfg
ownerID string
clusterID string
wantErr bool
}{
"gcp update owner ID": {
config: &config.GCPSEVES{
Measurements: newTestPCRs(),
},
ownerID: one64,
},
"gcp update cluster ID": {
config: &config.GCPSEVES{
Measurements: newTestPCRs(),
},
clusterID: one64,
},
"gcp update both": {
config: &config.GCPSEVES{
Measurements: newTestPCRs(),
},
ownerID: one64,
clusterID: one64,
},
"azure update owner ID": {
config: &config.AzureSEVSNP{
Measurements: newTestPCRs(),
},
ownerID: one64,
},
"azure update cluster ID": {
config: &config.AzureSEVSNP{
Measurements: newTestPCRs(),
},
clusterID: one64,
},
"azure update both": {
config: &config.AzureSEVSNP{
Measurements: newTestPCRs(),
},
ownerID: one64,
clusterID: one64,
},
"owner ID and cluster ID empty": {
config: &config.AzureSEVSNP{
Measurements: newTestPCRs(),
},
},
"invalid encoding": {
config: &config.GCPSEVES{
Measurements: newTestPCRs(),
},
ownerID: "invalid",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
err := updateInitMeasurements(tc.config, tc.ownerID, tc.clusterID)
if tc.wantErr {
assert.Error(err)
return
}
require.NoError(t, err)
m := tc.config.GetMeasurements()
for i := 0; i < len(m); i++ {
switch {
case i == int(measurements.PCRIndexClusterID) && tc.clusterID == "":
// should be deleted
_, ok := m[uint32(i)]
assert.False(ok)
case i == int(measurements.PCRIndexClusterID):
pcr, ok := m[uint32(i)]
assert.True(ok)
assert.Equal(pcrZeroUpdatedOne[:], pcr.Expected)
case i == int(measurements.PCRIndexOwnerID) && tc.ownerID == "":
// should be deleted
_, ok := m[uint32(i)]
assert.False(ok)
case i == int(measurements.PCRIndexOwnerID):
pcr, ok := m[uint32(i)]
assert.True(ok)
assert.Equal(pcrZeroUpdatedOne[:], pcr.Expected)
default:
if i >= 17 && i <= 22 {
assert.Equal(one, m[uint32(i)])
} else {
assert.Equal(zero, m[uint32(i)])
}
}
}
})
}
}
func TestValidatorUpdateInitMeasurementsTDX(t *testing.T) {
zero := measurements.WithAllBytes(0x00, true, measurements.TDXMeasurementLength)
one := measurements.WithAllBytes(0x11, true, measurements.TDXMeasurementLength)
one64 := base64.StdEncoding.EncodeToString(one.Expected[:])
oneHash := sha512.Sum384(one.Expected[:])
tdxZeroUpdatedOne := sha512.Sum384(append(zero.Expected[:], oneHash[:]...))
newTestTDXMeasurements := func() measurements.M {
return measurements.M{
0: measurements.WithAllBytes(0x00, true, measurements.TDXMeasurementLength),
1: measurements.WithAllBytes(0x00, true, measurements.TDXMeasurementLength),
2: measurements.WithAllBytes(0x00, true, measurements.TDXMeasurementLength),
3: measurements.WithAllBytes(0x00, true, measurements.TDXMeasurementLength),
4: measurements.WithAllBytes(0x00, true, measurements.TDXMeasurementLength),
}
}
testCases := map[string]struct {
measurements measurements.M
clusterID string
wantErr bool
}{
"QEMUT TDX update update cluster ID": {
measurements: newTestTDXMeasurements(),
clusterID: one64,
},
"cluster ID empty": {
measurements: newTestTDXMeasurements(),
},
"invalid encoding": {
measurements: newTestTDXMeasurements(),
clusterID: "invalid",
wantErr: true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
assert := assert.New(t)
cfg := &config.QEMUTDX{Measurements: tc.measurements}
err := updateInitMeasurements(cfg, "", tc.clusterID)
if tc.wantErr {
assert.Error(err)
return
}
assert.NoError(err)
for i := 0; i < len(tc.measurements); i++ {
switch {
case i == measurements.TDXIndexClusterID && tc.clusterID == "":
// should be deleted
_, ok := cfg.Measurements[uint32(i)]
assert.False(ok)
case i == measurements.TDXIndexClusterID:
pcr, ok := cfg.Measurements[uint32(i)]
assert.True(ok)
assert.Equal(tdxZeroUpdatedOne[:], pcr.Expected)
default:
assert.Equal(zero, cfg.Measurements[uint32(i)])
}
}
})
}
}