diff --git a/.golangci.yml b/.golangci.yml index 8d0b6ce105..7516f3ca8f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,6 +3,7 @@ run: modules-download-mode: readonly build-tags: - e2e + - contrast_unstable_api output: formats: diff --git a/.vscode/settings.json b/.vscode/settings.json index 10a7a917f0..c5b0ac5aa2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -5,7 +5,7 @@ "gopls": { "formatting.gofumpt": true, }, - "go.buildTags": "e2e", + "go.buildTags": "e2e contrast_unstable_api", "go.lintTool": "golangci-lint", "go.lintFlags": [ "--fast", diff --git a/cli/cmd/common.go b/cli/cmd/common.go index 86881a4d40..5c875d5db7 100644 --- a/cli/cmd/common.go +++ b/cli/cmd/common.go @@ -6,20 +6,11 @@ package cmd import ( "context" _ "embed" - "fmt" - "log/slog" "os" "path/filepath" "time" "github.com/edgelesssys/contrast/cli/telemetry" - "github.com/edgelesssys/contrast/internal/atls" - "github.com/edgelesssys/contrast/internal/attestation/certcache" - "github.com/edgelesssys/contrast/internal/attestation/snp" - "github.com/edgelesssys/contrast/internal/attestation/tdx" - "github.com/edgelesssys/contrast/internal/fsstore" - "github.com/edgelesssys/contrast/internal/logger" - "github.com/edgelesssys/contrast/internal/manifest" "github.com/spf13/cobra" ) @@ -81,40 +72,3 @@ func withTelemetry(runFunc func(*cobra.Command, []string) error) func(*cobra.Com return cmdErr } } - -// validatorsFromManifest returns a list of validators corresponding to the reference values in the given manifest. -func validatorsFromManifest(m *manifest.Manifest, log *slog.Logger, hostData []byte) ([]atls.Validator, error) { - kdsDir, err := cachedir("kds") - if err != nil { - return nil, fmt.Errorf("getting cache dir: %w", err) - } - log.Debug("Using KDS cache dir", "dir", kdsDir) - kdsCache := fsstore.New(kdsDir, log.WithGroup("kds-cache")) - kdsGetter := certcache.NewCachedHTTPSGetter(kdsCache, certcache.NeverGCTicker, log.WithGroup("kds-getter")) - - var validators []atls.Validator - - opts, err := m.SNPValidateOpts(kdsGetter) - if err != nil { - return nil, fmt.Errorf("getting SNP validate options: %w", err) - } - for _, opt := range opts { - opt.ValidateOpts.HostData = hostData - validators = append(validators, snp.NewValidator(opt.VerifyOpts, opt.ValidateOpts, - logger.NewWithAttrs(logger.NewNamed(log, "validator"), map[string]string{"tee-type": "snp"}), - )) - } - - tdxOpts, err := m.TDXValidateOpts() - if err != nil { - return nil, fmt.Errorf("generating TDX validation options: %w", err) - } - var mrConfigID [48]byte - copy(mrConfigID[:], hostData) - for _, opt := range tdxOpts { - opt.TdQuoteBodyOptions.MrConfigID = mrConfigID[:] - validators = append(validators, tdx.NewValidator(&tdx.StaticValidateOptsGenerator{Opts: opt}, logger.NewWithAttrs(logger.NewNamed(log, "validator"), map[string]string{"tee-type": "tdx"}))) - } - - return validators, nil -} diff --git a/cli/cmd/recover.go b/cli/cmd/recover.go index d77e5e4d34..2d3665b43d 100644 --- a/cli/cmd/recover.go +++ b/cli/cmd/recover.go @@ -14,6 +14,7 @@ import ( "github.com/edgelesssys/contrast/internal/grpc/dialer" "github.com/edgelesssys/contrast/internal/manifest" "github.com/edgelesssys/contrast/internal/userapi" + "github.com/edgelesssys/contrast/sdk" "github.com/spf13/cobra" ) @@ -73,7 +74,13 @@ func runRecover(cmd *cobra.Command, _ []string) error { return fmt.Errorf("decrypting seed: %w", err) } - validators, err := validatorsFromManifest(&m, log, flags.policy) + kdsDir, err := cachedir("kds") + if err != nil { + return fmt.Errorf("getting cache dir: %w", err) + } + log.Debug("Using KDS cache dir", "dir", kdsDir) + + validators, err := sdk.ValidatorsFromManifest(kdsDir, &m, log, flags.policy) if err != nil { return fmt.Errorf("getting validators: %w", err) } diff --git a/cli/cmd/set.go b/cli/cmd/set.go index e07445c8d5..6d2858138e 100644 --- a/cli/cmd/set.go +++ b/cli/cmd/set.go @@ -24,6 +24,7 @@ import ( "github.com/edgelesssys/contrast/internal/retry" "github.com/edgelesssys/contrast/internal/spinner" "github.com/edgelesssys/contrast/internal/userapi" + "github.com/edgelesssys/contrast/sdk" "github.com/spf13/cobra" "github.com/spf13/pflag" "google.golang.org/grpc/codes" @@ -98,7 +99,13 @@ func runSet(cmd *cobra.Command, args []string) error { return fmt.Errorf("checking policies match manifest: %w", err) } - validators, err := validatorsFromManifest(&m, log, flags.policy) + kdsDir, err := cachedir("kds") + if err != nil { + return fmt.Errorf("getting cache dir: %w", err) + } + log.Debug("Using KDS cache dir", "dir", kdsDir) + + validators, err := sdk.ValidatorsFromManifest(kdsDir, &m, log, flags.policy) if err != nil { return fmt.Errorf("getting validators: %w", err) } diff --git a/cli/cmd/verify.go b/cli/cmd/verify.go index 86f5753f93..decf0dfef4 100644 --- a/cli/cmd/verify.go +++ b/cli/cmd/verify.go @@ -4,18 +4,13 @@ package cmd import ( - "bytes" "crypto/sha256" - "encoding/json" "fmt" - "net" "os" "path/filepath" - "github.com/edgelesssys/contrast/internal/atls" - "github.com/edgelesssys/contrast/internal/grpc/dialer" "github.com/edgelesssys/contrast/internal/manifest" - "github.com/edgelesssys/contrast/internal/userapi" + "github.com/edgelesssys/contrast/sdk" "github.com/spf13/cobra" ) @@ -60,33 +55,19 @@ func runVerify(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("failed to read manifest file: %w", err) } - var m manifest.Manifest - if err := json.Unmarshal(manifestBytes, &m); err != nil { - return fmt.Errorf("failed to unmarshal manifest: %w", err) - } - if err := m.Validate(); err != nil { - return fmt.Errorf("validating manifest: %w", err) - } - validators, err := validatorsFromManifest(&m, log, flags.policy) + kdsDir, err := cachedir("kds") if err != nil { - return fmt.Errorf("getting validators: %w", err) + return fmt.Errorf("getting cache dir: %w", err) } - dialer := dialer.New(atls.NoIssuer, validators, atls.NoMetrics, &net.Dialer{}) + log.Debug("Using KDS cache dir", "dir", kdsDir) - log.Debug("Dialing coordinator", "endpoint", flags.coordinator) - conn, err := dialer.Dial(cmd.Context(), flags.coordinator) + sdkClient := sdk.NewWithSlog(log) + resp, err := sdkClient.GetCoordinatorState(cmd.Context(), kdsDir, manifestBytes, flags.coordinator, flags.policy) if err != nil { - return fmt.Errorf("Error: failed to dial coordinator: %w", err) + return fmt.Errorf("getting manifests: %w", err) } - defer conn.Close() - log.Debug("Getting manifest") - client := userapi.NewUserAPIClient(conn) - resp, err := client.GetManifests(cmd.Context(), &userapi.GetManifestsRequest{}) - if err != nil { - return fmt.Errorf("failed to get manifest: %w", err) - } log.Debug("Got response") fmt.Fprintln(cmd.OutOrStdout(), "✔️ Successfully verified Coordinator CVM based on reference values from manifest") @@ -109,9 +90,8 @@ func runVerify(cmd *cobra.Command, _ []string) error { fmt.Fprintf(cmd.OutOrStdout(), "✔️ Wrote Coordinator configuration and keys to %s\n", filepath.Join(flags.workspaceDir, verifyDir)) - currentManifest := resp.Manifests[len(resp.Manifests)-1] - if !bytes.Equal(currentManifest, manifestBytes) { - return fmt.Errorf("manifest active at Coordinator does not match expected manifest") + if err := sdkClient.Verify(manifestBytes, resp.Manifests); err != nil { + return fmt.Errorf("failed to verify Coordinator manifest: %w", err) } fmt.Fprintln(cmd.OutOrStdout(), "✔️ Manifest active at Coordinator matches expected manifest") diff --git a/packages/by-name/contrast/package.nix b/packages/by-name/contrast/package.nix index 4fdefa883a..cd426b2e27 100644 --- a/packages/by-name/contrast/package.nix +++ b/packages/by-name/contrast/package.nix @@ -24,7 +24,10 @@ let ; pname = "${contrast.pname}-e2e"; - tags = [ "e2e" ]; + tags = [ + "e2e" + "contrast_unstable_api" + ]; subPackages = [ "e2e/genpolicy" @@ -201,13 +204,15 @@ buildGoModule rec { "-X github.com/edgelesssys/contrast/internal/constants.KataGenpolicyVersion=${kata.genpolicy.version}" ]; + tags = [ "contrast_unstable_api" ]; + preCheck = '' export CGO_ENABLED=1 ''; checkPhase = '' runHook preCheck - go test -race ./... + go test -tags=contrast_unstable_api -race ./... runHook postCheck ''; diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000000..9dbd9878e1 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,10 @@ +# Contrast SDK + +**Caution:** This SDK is still under active development and not fit for external use yet. +Please expect breaking changes with new minor versions. + +The SDK allows writing programs that interact with a Contrast deployment like the CLI does, without relying on the CLI. + +# Building + +If you decide to use the unstable API and accept the risk of breakage, you need to set the Go build tag `contrast_unstable_api`. diff --git a/sdk/common.go b/sdk/common.go new file mode 100644 index 0000000000..b49f6c7a1f --- /dev/null +++ b/sdk/common.go @@ -0,0 +1,53 @@ +// Copyright 2024 Edgeless Systems GmbH +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build contrast_unstable_api + +package sdk + +import ( + "fmt" + "log/slog" + + "github.com/edgelesssys/contrast/internal/atls" + "github.com/edgelesssys/contrast/internal/attestation/certcache" + "github.com/edgelesssys/contrast/internal/attestation/snp" + "github.com/edgelesssys/contrast/internal/attestation/tdx" + "github.com/edgelesssys/contrast/internal/fsstore" + "github.com/edgelesssys/contrast/internal/logger" + "github.com/edgelesssys/contrast/internal/manifest" +) + +// ValidatorsFromManifest returns a list of validators corresponding to the reference values in the given manifest. +// Originally an unexported function in the contrast CLI. +// Can be made unexported again, if we decide to move all userapi calls from the CLI to the SDK. +func ValidatorsFromManifest(kdsDir string, m *manifest.Manifest, log *slog.Logger, coordinatorPolicyChecksum []byte) ([]atls.Validator, error) { + kdsCache := fsstore.New(kdsDir, log.WithGroup("kds-cache")) + kdsGetter := certcache.NewCachedHTTPSGetter(kdsCache, certcache.NeverGCTicker, log.WithGroup("kds-getter")) + + var validators []atls.Validator + + opts, err := m.SNPValidateOpts(kdsGetter) + if err != nil { + return nil, fmt.Errorf("getting SNP validate options: %w", err) + } + for _, opt := range opts { + opt.ValidateOpts.HostData = coordinatorPolicyChecksum + validators = append(validators, snp.NewValidator(opt.VerifyOpts, opt.ValidateOpts, + logger.NewWithAttrs(logger.NewNamed(log, "validator"), map[string]string{"tee-type": "snp"}), + )) + } + + tdxOpts, err := m.TDXValidateOpts() + if err != nil { + return nil, fmt.Errorf("generating TDX validation options: %w", err) + } + var mrConfigID [48]byte + copy(mrConfigID[:], coordinatorPolicyChecksum) + for _, opt := range tdxOpts { + opt.TdQuoteBodyOptions.MrConfigID = mrConfigID[:] + validators = append(validators, tdx.NewValidator(&tdx.StaticValidateOptsGenerator{Opts: opt}, logger.NewWithAttrs(logger.NewNamed(log, "validator"), map[string]string{"tee-type": "tdx"}))) + } + + return validators, nil +} diff --git a/sdk/sdk.go b/sdk/sdk.go new file mode 100644 index 0000000000..ff6b2d44da --- /dev/null +++ b/sdk/sdk.go @@ -0,0 +1,6 @@ +// Copyright 2024 Edgeless Systems GmbH +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build contrast_unstable_api + +package sdk diff --git a/sdk/verify.go b/sdk/verify.go new file mode 100644 index 0000000000..cb7b12a733 --- /dev/null +++ b/sdk/verify.go @@ -0,0 +1,107 @@ +// Copyright 2024 Edgeless Systems GmbH +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build contrast_unstable_api + +package sdk + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + + "github.com/edgelesssys/contrast/internal/atls" + "github.com/edgelesssys/contrast/internal/grpc/dialer" + "github.com/edgelesssys/contrast/internal/manifest" + "github.com/edgelesssys/contrast/internal/userapi" +) + +// Client is used to interact with a Contrast deployment. +type Client struct { + log *slog.Logger +} + +// New returns a Client with logging disabled. +func New() Client { + return Client{ + log: slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{})), + } +} + +// NewWithSlog can be used to configure how the SDK logs messages. +func NewWithSlog(log *slog.Logger) Client { + return Client{ + log: log, + } +} + +// GetCoordinatorState calls GetManifests on the coordinator's userapi via aTLS. +func (c Client) GetCoordinatorState(ctx context.Context, kdsDir string, manifestBytes []byte, endpoint string, policyHash []byte) (CoordinatorState, error) { + var m manifest.Manifest + if err := json.Unmarshal(manifestBytes, &m); err != nil { + return CoordinatorState{}, fmt.Errorf("unmarshalling manifest: %w", err) + } + if err := m.Validate(); err != nil { + return CoordinatorState{}, fmt.Errorf("validating manifest: %w", err) + } + + validators, err := ValidatorsFromManifest(kdsDir, &m, c.log, policyHash) + if err != nil { + return CoordinatorState{}, fmt.Errorf("getting validators: %w", err) + } + dialer := dialer.New(atls.NoIssuer, validators, atls.NoMetrics, &net.Dialer{}) + + c.log.Debug("Dialing coordinator", "endpoint", endpoint) + + conn, err := dialer.Dial(ctx, endpoint) + if err != nil { + return CoordinatorState{}, fmt.Errorf("dialing coordinator: %w", err) + } + defer conn.Close() + + c.log.Debug("Getting manifest") + + client := userapi.NewUserAPIClient(conn) + resp, err := client.GetManifests(ctx, &userapi.GetManifestsRequest{}) + if err != nil { + return CoordinatorState{}, fmt.Errorf("getting manifests: %w", err) + } + + return CoordinatorState{ + Manifests: resp.Manifests, + Policies: resp.Policies, + RootCA: resp.RootCA, + MeshCA: resp.MeshCA, + }, nil +} + +// Verify checks if a given manifest is the latest manifest in the given history. +// The expected manifest should be supplied by the caller, the history should be received from the coordinator. +func (Client) Verify(expectedManifest []byte, manifestHistory [][]byte) error { + if len(manifestHistory) == 0 { + return fmt.Errorf("manifest history is empty") + } + + currentManifest := manifestHistory[len(manifestHistory)-1] + if !bytes.Equal(currentManifest, expectedManifest) { + return fmt.Errorf("active manifest does not match expected manifest") + } + + return nil +} + +// CoordinatorState represents the state of the Contrast Coordinator at a fixed point in time. +type CoordinatorState struct { + // Manifests is a slice of manifests. It represents the manifest history of the Coordinator it was received from, sorted from oldest to newest. + Manifests [][]byte + // Policies contains all policies that have been referenced in any manifest in Manifests. Used to verify the guarantees a deployment had over its lifetime. + Policies [][]byte + // PEM-encoded certificate of the deployment's root CA. + RootCA []byte + // PEM-encoded certificate of the deployment's mesh CA. + MeshCA []byte +} diff --git a/sdk/verify_test.go b/sdk/verify_test.go new file mode 100644 index 0000000000..f79a709058 --- /dev/null +++ b/sdk/verify_test.go @@ -0,0 +1,49 @@ +// Copyright 2024 Edgeless Systems GmbH +// SPDX-License-Identifier: AGPL-3.0-only + +//go:build contrast_unstable_api + +package sdk + +import ( + "testing" +) + +func TestVerify(t *testing.T) { + tests := map[string]struct { + expectedManifest []byte + manifestHistory [][]byte + errMsg string + }{ + "Empty manifest history": { + expectedManifest: []byte("expected"), + manifestHistory: [][]byte{}, + errMsg: "manifest history is empty", + }, + "Matching manifest": { + expectedManifest: []byte("expected"), + manifestHistory: [][]byte{[]byte("old"), []byte("expected")}, + }, + "Non-matching manifest": { + expectedManifest: []byte("expected"), + manifestHistory: [][]byte{[]byte("old"), []byte("current")}, + errMsg: "active manifest does not match expected manifest", + }, + "Matching manifest is not latest": { + expectedManifest: []byte("expected"), + manifestHistory: [][]byte{[]byte("expected"), []byte("current")}, + errMsg: "active manifest does not match expected manifest", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + client := Client{} + err := client.Verify(tt.expectedManifest, tt.manifestHistory) + + if err != nil && err.Error() != tt.errMsg { + t.Errorf("actual error: '%v', expected error: '%v'", err, tt.errMsg) + } + }) + } +}