constellation/cli/internal/cmd/verify.go
2024-02-08 14:20:01 +00:00

544 lines
18 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/hex"
"encoding/json"
"errors"
"fmt"
"net"
"sort"
"strconv"
"strings"
tpmProto "github.com/google/go-tpm-tools/proto/tpm"
"github.com/edgelesssys/constellation/v2/internal/api/attestationconfigapi"
"github.com/edgelesssys/constellation/v2/internal/atls"
"github.com/edgelesssys/constellation/v2/internal/attestation/choose"
"github.com/edgelesssys/constellation/v2/internal/attestation/measurements"
"github.com/edgelesssys/constellation/v2/internal/attestation/snp"
"github.com/edgelesssys/constellation/v2/internal/attestation/variant"
"github.com/edgelesssys/constellation/v2/internal/attestation/vtpm"
"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/crypto"
"github.com/edgelesssys/constellation/v2/internal/file"
"github.com/edgelesssys/constellation/v2/internal/grpc/dialer"
"github.com/edgelesssys/constellation/v2/internal/verify"
"github.com/edgelesssys/constellation/v2/verify/verifyproto"
"github.com/spf13/afero"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"google.golang.org/grpc"
)
// NewVerifyCmd returns a new cobra.Command for the verify command.
func NewVerifyCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "verify",
Short: "Verify the confidential properties of a Constellation cluster",
Long: "Verify the confidential properties of a Constellation cluster.\n" +
"If arguments aren't specified, values are read from `" + constants.StateFilename + "`.",
Args: cobra.ExactArgs(0),
RunE: runVerify,
}
cmd.Flags().String("cluster-id", "", "expected cluster identifier")
cmd.Flags().StringP("output", "o", "", "print the attestation document in the output format {json|raw}")
cmd.Flags().StringP("node-endpoint", "e", "", "endpoint of the node to verify, passed as HOST[:PORT]")
return cmd
}
type verifyFlags struct {
rootFlags
endpoint string
ownerID string
clusterID string
output string
}
func (f *verifyFlags) parse(flags *pflag.FlagSet) error {
if err := f.rootFlags.parse(flags); err != nil {
return err
}
var err error
f.output, err = flags.GetString("output")
if err != nil {
return fmt.Errorf("getting 'output' flag: %w", err)
}
f.endpoint, err = flags.GetString("node-endpoint")
if err != nil {
return fmt.Errorf("getting 'node-endpoint' flag: %w", err)
}
f.clusterID, err = flags.GetString("cluster-id")
if err != nil {
return fmt.Errorf("getting 'cluster-id' flag: %w", err)
}
return nil
}
type verifyCmd struct {
fileHandler file.Handler
flags verifyFlags
log debugLog
}
func runVerify(cmd *cobra.Command, _ []string) error {
log, err := newCLILogger(cmd)
if err != nil {
return fmt.Errorf("creating logger: %w", err)
}
fileHandler := file.NewHandler(afero.NewOsFs())
verifyClient := &constellationVerifier{
dialer: dialer.New(nil, nil, &net.Dialer{}),
log: log,
}
formatterFactory := func(output string, attestation variant.Variant, log debugLog) (attestationDocFormatter, error) {
if output == "json" && (!attestation.Equal(variant.AzureSEVSNP{}) && !attestation.Equal(variant.AWSSEVSNP{})) {
return nil, errors.New("json output is only supported for Azure SEV-SNP and AWS SEV-SNP")
}
switch output {
case "json":
return &jsonAttestationDocFormatter{log}, nil
case "raw":
return &rawAttestationDocFormatter{log}, nil
case "":
return &defaultAttestationDocFormatter{log}, nil
default:
return nil, fmt.Errorf("invalid output value for formatter: %s", output)
}
}
v := &verifyCmd{
fileHandler: fileHandler,
log: log,
}
if err := v.flags.parse(cmd.Flags()); err != nil {
return err
}
v.log.Debug(fmt.Sprintf("Using flags: %+v", v.flags))
fetcher := attestationconfigapi.NewFetcher()
return v.verify(cmd, verifyClient, formatterFactory, fetcher)
}
type formatterFactory func(output string, attestation variant.Variant, log debugLog) (attestationDocFormatter, error)
func (c *verifyCmd) verify(cmd *cobra.Command, verifyClient verifyClient, factory formatterFactory, configFetcher attestationconfigapi.Fetcher) error {
c.log.Debug(fmt.Sprintf("Loading configuration file from %q", c.flags.pathPrefixer.PrefixPrintablePath(constants.ConfigFilename)))
conf, err := config.New(c.fileHandler, constants.ConfigFilename, configFetcher, c.flags.force)
var configValidationErr *config.ValidationError
if errors.As(err, &configValidationErr) {
cmd.PrintErrln(configValidationErr.LongMessage())
}
if err != nil {
return fmt.Errorf("loading config file: %w", err)
}
stateFile, err := state.ReadFromFile(c.fileHandler, constants.StateFilename)
if err != nil {
return fmt.Errorf("reading state file: %w", err)
}
if err := stateFile.Validate(state.PostInit, conf.GetAttestationConfig().GetVariant()); err != nil {
return fmt.Errorf("validating state file: %w", err)
}
ownerID, clusterID, err := c.validateIDFlags(cmd, stateFile)
if err != nil {
return err
}
endpoint, err := c.validateEndpointFlag(cmd, stateFile)
if err != nil {
return err
}
var maaURL string
if stateFile.Infrastructure.Azure != nil {
maaURL = stateFile.Infrastructure.Azure.AttestationURL
}
conf.UpdateMAAURL(maaURL)
c.log.Debug("Updating expected PCRs")
attConfig := conf.GetAttestationConfig()
if err := updateInitMeasurements(attConfig, ownerID, clusterID); err != nil {
return fmt.Errorf("updating expected PCRs: %w", err)
}
c.log.Debug(fmt.Sprintf("Creating aTLS Validator for %s", conf.GetAttestationConfig().GetVariant()))
validator, err := choose.Validator(attConfig, warnLogger{cmd: cmd, log: c.log})
if err != nil {
return fmt.Errorf("creating aTLS validator: %w", err)
}
nonce, err := crypto.GenerateRandomBytes(32)
if err != nil {
return fmt.Errorf("generating random nonce: %w", err)
}
c.log.Debug(fmt.Sprintf("Generated random nonce: %x", nonce))
rawAttestationDoc, err := verifyClient.Verify(
cmd.Context(),
endpoint,
&verifyproto.GetAttestationRequest{
Nonce: nonce,
},
validator,
)
if err != nil {
return fmt.Errorf("verifying: %w", err)
}
// certificates are only available for Azure SEV-SNP and AWS SEV-SNP
formatter, err := factory(c.flags.output, conf.GetAttestationConfig().GetVariant(), c.log)
if err != nil {
return fmt.Errorf("creating formatter: %w", err)
}
attDocOutput, err := formatter.format(
cmd.Context(),
rawAttestationDoc,
(!attConfig.GetVariant().Equal(variant.AzureSEVSNP{}) && !attConfig.GetVariant().Equal(variant.AWSSEVSNP{})),
attConfig,
)
if err != nil {
return fmt.Errorf("printing attestation document: %w", err)
}
cmd.Println(attDocOutput)
cmd.PrintErrln("Verification OK")
return nil
}
func (c *verifyCmd) validateIDFlags(cmd *cobra.Command, stateFile *state.State) (ownerID, clusterID string, err error) {
ownerID, clusterID = c.flags.ownerID, c.flags.clusterID
if c.flags.clusterID == "" {
cmd.PrintErrf("Using ID from %q. Specify --cluster-id to override this.\n", c.flags.pathPrefixer.PrefixPrintablePath(constants.StateFilename))
clusterID = stateFile.ClusterValues.ClusterID
}
if ownerID == "" {
// We don't want to print warnings until this is implemented again
// cmd.PrintErrf("Using ID from %q. Specify --owner-id to override this.\n", c.flags.pathPrefixer.PrefixPrintablePath(constants.StateFilename))
ownerID = stateFile.ClusterValues.OwnerID
}
// Validate
if ownerID == "" && clusterID == "" {
return "", "", errors.New("cluster-id not provided to verify the cluster")
}
return ownerID, clusterID, nil
}
func (c *verifyCmd) validateEndpointFlag(cmd *cobra.Command, stateFile *state.State) (string, error) {
endpoint := c.flags.endpoint
if endpoint == "" {
cmd.PrintErrf("Using endpoint from %q. Specify --node-endpoint to override this.\n", c.flags.pathPrefixer.PrefixPrintablePath(constants.StateFilename))
endpoint = stateFile.Infrastructure.ClusterEndpoint
}
endpoint, err := addPortIfMissing(endpoint, constants.VerifyServiceNodePortGRPC)
if err != nil {
return "", fmt.Errorf("validating endpoint argument: %w", err)
}
return endpoint, nil
}
// an attestationDocFormatter formats the attestation document.
type attestationDocFormatter interface {
// format returns the raw or formatted attestation doc depending on the rawOutput argument.
format(ctx context.Context, docString string, PCRsOnly bool, attestationCfg config.AttestationCfg) (string, error)
}
type jsonAttestationDocFormatter struct {
log debugLog
}
// format returns the json formatted attestation doc.
func (f *jsonAttestationDocFormatter) format(ctx context.Context, docString string, _ bool,
attestationCfg config.AttestationCfg,
) (string, error) {
var doc attestationDoc
if err := json.Unmarshal([]byte(docString), &doc); err != nil {
return "", fmt.Errorf("unmarshal attestation document: %w", err)
}
instanceInfo, err := extractInstanceInfo(doc)
if err != nil {
return "", fmt.Errorf("unmarshalling instance info: %w", err)
}
report, err := verify.NewReport(ctx, instanceInfo, attestationCfg, f.log)
if err != nil {
return "", fmt.Errorf("parsing SNP report: %w", err)
}
jsonBytes, err := json.Marshal(report)
return string(jsonBytes), err
}
type rawAttestationDocFormatter struct {
log debugLog
}
// format returns the raw attestation doc.
func (f *rawAttestationDocFormatter) format(_ context.Context, docString string, _ bool,
_ config.AttestationCfg,
) (string, error) {
b := &strings.Builder{}
b.WriteString("Attestation Document:\n")
b.WriteString(fmt.Sprintf("%s\n", docString))
return b.String(), nil
}
type defaultAttestationDocFormatter struct {
log debugLog
}
// format returns the formatted attestation doc.
func (f *defaultAttestationDocFormatter) format(ctx context.Context, docString string, PCRsOnly bool,
attestationCfg config.AttestationCfg,
) (string, error) {
b := &strings.Builder{}
b.WriteString("Attestation Document:\n")
var doc attestationDoc
if err := json.Unmarshal([]byte(docString), &doc); err != nil {
return "", fmt.Errorf("unmarshal attestation document: %w", err)
}
if err := f.parseQuotes(b, doc.Attestation.Quotes, attestationCfg.GetMeasurements()); err != nil {
return "", fmt.Errorf("parse quote: %w", err)
}
if PCRsOnly {
return b.String(), nil
}
instanceInfo, err := extractInstanceInfo(doc)
if err != nil {
return "", fmt.Errorf("unmarshalling instance info: %w", err)
}
report, err := verify.NewReport(ctx, instanceInfo, attestationCfg, f.log)
if err != nil {
return "", fmt.Errorf("parsing SNP report: %w", err)
}
return report.FormatString(b)
}
// parseQuotes parses the base64-encoded quotes and writes their details to the output builder.
func (f *defaultAttestationDocFormatter) parseQuotes(b *strings.Builder, quotes []*tpmProto.Quote, expectedPCRs measurements.M) error {
writeIndentfln(b, 1, "Quote:")
var pcrNumbers []uint32
for pcrNum := range expectedPCRs {
pcrNumbers = append(pcrNumbers, pcrNum)
}
sort.Slice(pcrNumbers, func(i, j int) bool { return pcrNumbers[i] < pcrNumbers[j] })
for _, pcrNum := range pcrNumbers {
expectedPCR := expectedPCRs[pcrNum]
pcrIdx, err := vtpm.GetSHA256QuoteIndex(quotes)
if err != nil {
return fmt.Errorf("get SHA256 quote index: %w", err)
}
actualPCR, ok := quotes[pcrIdx].Pcrs.Pcrs[pcrNum]
if !ok {
return fmt.Errorf("PCR %d not found in quote", pcrNum)
}
writeIndentfln(b, 2, "PCR %d (Strict: %t):", pcrNum, !expectedPCR.ValidationOpt)
writeIndentfln(b, 3, "Expected:\t%x", expectedPCR.Expected)
writeIndentfln(b, 3, "Actual:\t\t%x", actualPCR)
}
return nil
}
// attestationDoc is the attestation document returned by the verifier.
type attestationDoc struct {
Attestation struct {
AkPub string `json:"ak_pub"`
Quotes []*tpmProto.Quote `json:"quotes"`
EventLog string `json:"event_log"`
TeeAttestation interface{} `json:"TeeAttestation"`
} `json:"Attestation"`
InstanceInfo string `json:"InstanceInfo"`
UserData string `json:"UserData"`
}
type constellationVerifier struct {
dialer grpcInsecureDialer
log debugLog
}
// Verify retrieves an attestation statement from the Constellation and verifies it using the validator.
func (v *constellationVerifier) Verify(
ctx context.Context, endpoint string, req *verifyproto.GetAttestationRequest, validator atls.Validator,
) (string, error) {
v.log.Debug(fmt.Sprintf("Dialing endpoint: %q", endpoint))
conn, err := v.dialer.DialInsecure(ctx, endpoint)
if err != nil {
return "", fmt.Errorf("dialing init server: %w", err)
}
defer conn.Close()
client := verifyproto.NewAPIClient(conn)
v.log.Debug("Sending attestation request")
resp, err := client.GetAttestation(ctx, req)
if err != nil {
return "", fmt.Errorf("getting attestation: %w", err)
}
v.log.Debug("Verifying attestation")
signedData, err := validator.Validate(ctx, resp.Attestation, req.Nonce)
if err != nil {
return "", fmt.Errorf("validating attestation: %w", err)
}
if !bytes.Equal(signedData, []byte(constants.ConstellationVerifyServiceUserData)) {
return "", errors.New("signed data in attestation does not match expected user data")
}
return string(resp.Attestation), nil
}
type verifyClient interface {
Verify(ctx context.Context, endpoint string, req *verifyproto.GetAttestationRequest, validator atls.Validator) (string, error)
}
type grpcInsecureDialer interface {
DialInsecure(ctx context.Context, endpoint string) (conn *grpc.ClientConn, err error)
}
// writeIndentfln writes a formatted string to the builder with the given indentation level
// and a newline at the end.
func writeIndentfln(b *strings.Builder, indentLvl int, format string, args ...any) {
for i := 0; i < indentLvl; i++ {
b.WriteByte('\t')
}
b.WriteString(fmt.Sprintf(format+"\n", args...))
}
func extractInstanceInfo(doc attestationDoc) (snp.InstanceInfo, error) {
instanceInfoString, err := base64.StdEncoding.DecodeString(doc.InstanceInfo)
if err != nil {
return snp.InstanceInfo{}, fmt.Errorf("decode instance info: %w", err)
}
var instanceInfo snp.InstanceInfo
if err := json.Unmarshal(instanceInfoString, &instanceInfo); err != nil {
return snp.InstanceInfo{}, fmt.Errorf("unmarshal instance info: %w", err)
}
return instanceInfo, nil
}
func addPortIfMissing(endpoint string, defaultPort int) (string, error) {
if endpoint == "" {
return "", errors.New("endpoint is empty")
}
_, _, err := net.SplitHostPort(endpoint)
if err == nil {
return endpoint, nil
}
if strings.Contains(err.Error(), "missing port in address") {
return net.JoinHostPort(endpoint, strconv.Itoa(defaultPort)), nil
}
return "", err
}
// UpdateInitMeasurements sets the owner and cluster measurement values in the attestation config depending on the
// attestation variant.
func updateInitMeasurements(config config.AttestationCfg, ownerID, clusterID string) error {
m := config.GetMeasurements()
switch config.GetVariant() {
case variant.AWSNitroTPM{}, variant.AWSSEVSNP{},
variant.AzureTrustedLaunch{}, variant.AzureSEVSNP{}, variant.AzureTDX{}, // AzureTDX also uses a vTPM for measurements
variant.GCPSEVES{},
variant.QEMUVTPM{}:
if err := updateMeasurementTPM(m, uint32(measurements.PCRIndexOwnerID), ownerID); err != nil {
return err
}
return updateMeasurementTPM(m, uint32(measurements.PCRIndexClusterID), clusterID)
case variant.QEMUTDX{}:
// Measuring ownerID is currently not implemented for Constellation
// Since adding support for measuring ownerID to TDX would require additional code changes,
// the current implementation does not support it, but can be changed if we decide to add support in the future
return updateMeasurementTDX(m, uint32(measurements.TDXIndexClusterID), clusterID)
default:
return errors.New("selecting attestation variant: unknown attestation variant")
}
}
// updateMeasurementTDX updates the TDX measurement value in the attestation config for the given measurement index.
func updateMeasurementTDX(m measurements.M, measurementIdx uint32, encoded string) error {
if encoded == "" {
delete(m, measurementIdx)
return nil
}
decoded, err := decodeMeasurement(encoded)
if err != nil {
return err
}
// new_measurement_value := hash(old_measurement_value || data_to_extend)
// Since we use the DG.MR.RTMR.EXTEND call to extend the register, data_to_extend is the hash of our input
hashedInput := sha512.Sum384(decoded)
oldExpected := m[measurementIdx].Expected
expectedMeasurementSum := sha512.Sum384(append(oldExpected[:], hashedInput[:]...))
m[measurementIdx] = measurements.Measurement{
Expected: expectedMeasurementSum[:],
ValidationOpt: m[measurementIdx].ValidationOpt,
}
return nil
}
// updateMeasurementTPM updates the TPM measurement value in the attestation config for the given measurement index.
func updateMeasurementTPM(m measurements.M, measurementIdx uint32, encoded string) error {
if encoded == "" {
delete(m, measurementIdx)
return nil
}
decoded, err := decodeMeasurement(encoded)
if err != nil {
return err
}
// new_pcr_value := hash(old_pcr_value || data_to_extend)
// Since we use the TPM2_PCR_Event call to extend the PCR, data_to_extend is the hash of our input
hashedInput := sha256.Sum256(decoded)
oldExpected := m[measurementIdx].Expected
expectedMeasurement := sha256.Sum256(append(oldExpected[:], hashedInput[:]...))
m[measurementIdx] = measurements.Measurement{
Expected: expectedMeasurement[:],
ValidationOpt: m[measurementIdx].ValidationOpt,
}
return nil
}
// decodeMeasurement is a utility function that decodes the given string as hex or base64.
func decodeMeasurement(encoded string) ([]byte, error) {
decoded, err := hex.DecodeString(encoded)
if err != nil {
hexErr := err
decoded, err = base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("input [%s] could neither be hex decoded (%w) nor base64 decoded (%w)", encoded, hexErr, err)
}
}
return decoded, nil
}