diff --git a/cli/cmd/common.go b/cli/cmd/common.go index 56e1ac058c4..f597f287591 100644 --- a/cli/cmd/common.go +++ b/cli/cmd/common.go @@ -34,6 +34,7 @@ const ( rulesFilename = "rules.rego" layersCacheFilename = "layers-cache.json" latestTransitionHashFilename = "latest-transition" + nextTransitionHashFilename = "next-transition" historyFilename = "history.yml" verifyDir = "verify" ) diff --git a/cli/cmd/set.go b/cli/cmd/set.go index c226aef2470..0a4dd381c81 100644 --- a/cli/cmd/set.go +++ b/cli/cmd/set.go @@ -52,12 +52,14 @@ issuer certificates.`, } cmd.SetOut(commandOut()) + cmd.Flags().Bool("dry-run", false, "compute the next transition hash without setting the manifest at the coordinator") cmd.Flags().StringP("manifest", "m", manifestFilename, "path to manifest (.json) file") cmd.Flags().StringP("coordinator", "c", "", "endpoint the coordinator can be reached at") - must(cobra.MarkFlagRequired(cmd.Flags(), "coordinator")) + cmd.MarkFlagsOneRequired("dry-run", "coordinator") cmd.Flags().String("workload-owner-key", workloadOwnerPEM, "path to workload owner key (.pem) file") cmd.Flags().Bool("atomic", false, "only set the manifest if the coordinator's state matches the latest transition hash") cmd.Flags().String("latest-transition", "", "latest transition hash set at the coordinator (hex string)") + cmd.Flags().StringP("signature", "s", "", "path to a signed transition (DER) file") return cmd } @@ -91,6 +93,12 @@ func runSet(cmd *cobra.Command, args []string) error { } else if err != nil { return fmt.Errorf("loading workload owner key: %w", err) } + signatureBytes, err := os.ReadFile(flags.signaturePath) + if errors.Is(err, os.ErrNotExist) { + signatureBytes = nil + } else if err != nil { + return fmt.Errorf("reading signature file: %w", err) + } paths, err := findYamlFiles(args) if err != nil { @@ -110,7 +118,7 @@ func runSet(cmd *cobra.Command, args []string) error { } var previousTransitionHash []byte - if flags.atomic { + if flags.dryRun || flags.atomic { if flags.latestTransition == "" { data, err := os.ReadFile(filepath.Join(flags.workspaceDir, verifyDir, latestTransitionHashFilename)) if err != nil && !errors.Is(err, os.ErrNotExist) { @@ -126,6 +134,20 @@ func runSet(cmd *cobra.Command, args []string) error { } } + if flags.dryRun { + tr := &history.Transition{ + ManifestHash: history.Digest(manifestBytes), + PreviousTransitionHash: [history.HashSize]byte(previousTransitionHash), + } + transitionHash := tr.Digest() + transitionHashHex := hex.EncodeToString(transitionHash[:]) + if err := os.WriteFile(filepath.Join(flags.workspaceDir, nextTransitionHashFilename), []byte(transitionHashHex), 0o644); err != nil { + return fmt.Errorf("writing transition hash: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "✔️ Transition hash %s written to %s\n", transitionHashHex, filepath.Join(flags.workspaceDir, nextTransitionHashFilename)) + return nil + } + kdsGetter, err := cachedHTTPSGetter(log) if err != nil { return fmt.Errorf("configuring KDS cache: %w", err) @@ -153,6 +175,7 @@ func runSet(cmd *cobra.Command, args []string) error { Manifest: manifestBytes, Policies: getInitdataDocuments(policies), PreviousTransitionHash: previousTransitionHash, + Signature: signatureBytes, } resp, err := setLoop(cmd.Context(), client, cmd.OutOrStdout(), req) if err != nil { @@ -204,6 +227,8 @@ type setFlags struct { workloadOwnerKeyPath string atomic bool latestTransition string + dryRun bool + signaturePath string workspaceDir string } @@ -231,11 +256,17 @@ func parseSetFlags(cmd *cobra.Command) (*setFlags, error) { if err != nil { return nil, fmt.Errorf("getting latest-transition flag: %w", err) } - if !flags.atomic && flags.latestTransition != "" { return nil, fmt.Errorf("\"latest-transition\" flag cannot be set without \"atomic\" flag") } - + flags.dryRun, err = cmd.Flags().GetBool("dry-run") + if err != nil { + return nil, fmt.Errorf("getting dry-run flag: %w", err) + } + flags.signaturePath, err = cmd.Flags().GetString("signature") + if err != nil { + return nil, fmt.Errorf("getting signature flag: %w", err) + } flags.workspaceDir, err = cmd.Flags().GetString("workspace-dir") if err != nil { return nil, fmt.Errorf("getting workspace-dir flag: %w", err) diff --git a/coordinator/internal/userapi/userapi.go b/coordinator/internal/userapi/userapi.go index 50b2e199356..88020882e10 100644 --- a/coordinator/internal/userapi/userapi.go +++ b/coordinator/internal/userapi/userapi.go @@ -18,6 +18,7 @@ import ( "crypto/ecdsa" "crypto/rsa" "crypto/x509" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -96,9 +97,14 @@ func (s *Server) SetManifest(ctx context.Context, req *userapi.SetManifestReques if oldState != nil { oldManifest := oldState.Manifest() // Subsequent SetManifest call, check permissions of caller. - if err := validatePeer(ctx, oldManifest.WorkloadOwnerPubKeys); err != nil { - s.logger.Warn("SetManifest peer validation failed", "err", err) - return nil, status.Errorf(codes.PermissionDenied, "validating peer: %v", err) + if err := validateSignature(oldManifest.WorkloadOwnerPubKeys, oldState.LatestTransition().TransitionHash, req); err != nil && !errors.Is(err, ErrNoSignature) { + s.logger.Warn("SetManifest signature validation failed", "err", err) + return nil, status.Errorf(codes.PermissionDenied, "validating manifest signature: %v", err) + } else if errors.Is(err, ErrNoSignature) { + if err := validatePeer(ctx, oldManifest.WorkloadOwnerPubKeys); err != nil { + s.logger.Warn("SetManifest peer validation failed", "err", err) + return nil, status.Errorf(codes.PermissionDenied, "validating peer: %v", err) + } } se = oldState.SeedEngine() if slices.Compare(oldManifest.SeedshareOwnerPubKeys, m.SeedshareOwnerPubKeys) != 0 { @@ -246,6 +252,34 @@ func (a *seedAuthorizer) AuthorizeByManifest(ctx context.Context, mnfst *manifes return se, meshKey, nil } +func validateSignature(keys []manifest.HexString, latestTransitionHash [history.HashSize]byte, req *userapi.SetManifestRequest) error { + if len(keys) == 0 { + return errors.New("setting manifest is disabled") + } + if len(req.Signature) == 0 { + return ErrNoSignature + } + + tr := &history.Transition{ + ManifestHash: history.Digest(req.Manifest), + PreviousTransitionHash: latestTransitionHash, + } + hash := tr.Digest() + // Hash again because we do hash+sign on the blob that contains the hex-encoded next transition hash. + hash = history.Digest(hex.AppendEncode(nil, hash[:])) + + for _, key := range keys { + trustedWorkloadOwnerKey, err := manifest.ParseWorkloadOwnerPublicKey(key) + if err != nil { + return fmt.Errorf("parsing key: %w", err) + } + if ecdsa.VerifyASN1(trustedWorkloadOwnerKey, hash[:], req.Signature) { + return nil + } + } + return errors.New("invalid manifest signature") +} + func validatePeer(ctx context.Context, keys []manifest.HexString) error { if len(keys) == 0 { return errors.New("setting manifest is disabled") @@ -297,4 +331,6 @@ var ( ErrAlreadyRecovered = errors.New("coordinator is already recovered") // ErrNeedsRecovery is returned if state exists, but no secrets are available, e.g. after restart. ErrNeedsRecovery = errors.New("coordinator is in recovery mode") + // ErrNoSignature is returned if a manifest update request does not contain a signature. + ErrNoSignature = errors.New("manifest signature is empty") ) diff --git a/coordinator/internal/userapi/userapi_test.go b/coordinator/internal/userapi/userapi_test.go index fd113b425fd..6d021f51d32 100644 --- a/coordinator/internal/userapi/userapi_test.go +++ b/coordinator/internal/userapi/userapi_test.go @@ -8,10 +8,12 @@ import ( "context" "crypto" "crypto/ecdsa" + "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/tls" "crypto/x509" + "encoding/hex" "encoding/json" "encoding/pem" "errors" @@ -251,6 +253,33 @@ func TestSetManifest(t *testing.T) { _, err = coordinator.SetManifest(ctx, req) require.Error(err) }) + + t.Run("signed manifest update", func(t *testing.T) { + require := require.New(t) + + coordinator := newCoordinator() + ctx := rpcContext(t.Context(), trustedKey) + m, err := json.Marshal(manifestWithTrustedKey) + require.NoError(err) + tr := history.Transition{ + ManifestHash: history.Digest(m), + } + prevTransitionHash := tr.Digest() + tr = history.Transition{ + ManifestHash: history.Digest(m), + PreviousTransitionHash: prevTransitionHash, + } + nextTransitionHash := tr.Digest() + nextTransitionHashHex := hex.EncodeToString(nextTransitionHash[:]) + sig, err := ecdsa.SignASN1(rand.Reader, trustedKey, []byte(nextTransitionHashHex)) + require.NoError(err) + req := &userapi.SetManifestRequest{ + Manifest: m, + Signature: sig, + } + _, err = coordinator.SetManifest(ctx, req) + require.NoError(err) + }) } func TestGetManifests(t *testing.T) { diff --git a/docs/docs/howto/manifest-update.md b/docs/docs/howto/manifest-update.md index 161464b9439..0c2d00a72e9 100644 --- a/docs/docs/howto/manifest-update.md +++ b/docs/docs/howto/manifest-update.md @@ -44,3 +44,24 @@ for all your application resources. As described above, a manifest update triggers rotation of the mesh CA certificate, the intermediate CA certificate and the workload certificates. You can use this to force a certificate rotation or to constrain the certificate validity period. Setting the current manifest once more causes a certificate rotation, without changing the reference values enforced by the Coordinator. + +### Signed manifest updates + +Only authorized users with access to a trusted workload owner key can set a manifest at the Coordinator. +During a normal manifest update, the workload owner key is passed to the CLI and used directly in the TLS handshake with the Coordinator. +It's also possible to use externally managed keys, for example, in a hardware security module (HSM) or a cloud key management service (KMS). +In this case, the manifest update can be signed with the workload owner key and only the signature is needed to set the manifest. + +The signature is generated over the manifest content and the latest transition hash, which is obtained from a previous `contrast verify`. +Use the `--dry-run` flag to get the blob that needs to be signed: + +```sh +contrast set --dry-run resources/ +``` + +Then, sign the blob with the workload owner key and pass the signature to the CLI: + +```sh +openssl dgst -sha256 -sign -out transition.sig next-transition +contrast set -c "${coordinator}:1313" -s transition.sig resources/ +``` diff --git a/e2e/coordinator/coordinator_test.go b/e2e/coordinator/coordinator_test.go index 254d56cd7d8..d1d0f67cc86 100644 --- a/e2e/coordinator/coordinator_test.go +++ b/e2e/coordinator/coordinator_test.go @@ -7,6 +7,9 @@ package coordinator import ( "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" "flag" "os" "path/filepath" @@ -107,6 +110,45 @@ func TestCoordinator(t *testing.T) { require.NoError(err) require.NoError(ct.RunSet(ctx, "--atomic", "--latest-transition", string(transitionHash))) }) + + t.Run("signed manifest update", func(t *testing.T) { + require := require.New(t) + ct := contrasttest.New(t) + + resources := kuberesource.CoordinatorBundle() + resources = kuberesource.PatchRuntimeHandlers(resources, runtimeHandler) + resources = kuberesource.AddPortForwarders(resources) + ct.Init(t, resources) + + require.True(t.Run("generate", ct.Generate), "contrast generate needs to succeed for subsequent tests") + require.True(t.Run("apply", ct.Apply), "Kubernetes resources need to be applied for subsequent tests") + require.True(t.Run("set", ct.Set), "contrast set needs to succeed for subsequent tests") + require.True(t.Run("verify", ct.Verify), "contrast verify needs to succeed for subsequent tests") + + ctx, cancel := context.WithTimeout(t.Context(), ct.FactorPlatformTimeout(2*time.Minute)) + t.Cleanup(cancel) + + require.NoError(ct.RunSet(ctx, "--dry-run")) + + transitionHash, err := os.ReadFile(filepath.Join(ct.WorkDir, "next-transition")) + require.NoError(err) + + keyBytes, err := os.ReadFile(filepath.Join(ct.WorkDir, "workload-owner.pem")) + require.NoError(err) + + key, err := manifest.ParseWorkloadOwnerPrivateKey(keyBytes) + require.NoError(err) + + transitionHashShaSum := sha256.Sum256(transitionHash) + sig, err := ecdsa.SignASN1(rand.Reader, key, transitionHashShaSum[:]) + require.NoError(err) + + require.NoError(os.WriteFile(filepath.Join(ct.WorkDir, "transition.sig"), sig, 0o644)) + // Remove the key so that it is not passed to the CLI. + require.NoError(os.Remove(filepath.Join(ct.WorkDir, "workload-owner.pem"))) + + require.NoError(ct.RunSet(ctx, "--signature", filepath.Join(ct.WorkDir, "transition.sig"))) + }) } func TestMain(m *testing.M) { diff --git a/internal/userapi/userapi.pb.go b/internal/userapi/userapi.pb.go index 4755be053ab..3b791ddf40a 100644 --- a/internal/userapi/userapi.pb.go +++ b/internal/userapi/userapi.pb.go @@ -26,6 +26,7 @@ type SetManifestRequest struct { Manifest []byte `protobuf:"bytes,1,opt,name=Manifest,proto3" json:"Manifest,omitempty"` Policies [][]byte `protobuf:"bytes,2,rep,name=Policies,proto3" json:"Policies,omitempty"` PreviousTransitionHash []byte `protobuf:"bytes,3,opt,name=PreviousTransitionHash,proto3" json:"PreviousTransitionHash,omitempty"` + Signature []byte `protobuf:"bytes,4,opt,name=Signature,proto3" json:"Signature,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -81,6 +82,13 @@ func (x *SetManifestRequest) GetPreviousTransitionHash() []byte { return nil } +func (x *SetManifestRequest) GetSignature() []byte { + if x != nil { + return x.Signature + } + return nil +} + type SetManifestResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // PEM-encoded certificate @@ -514,11 +522,12 @@ var File_userapi_proto protoreflect.FileDescriptor const file_userapi_proto_rawDesc = "" + "\n" + - "\ruserapi.proto\x12\x1cedgelesssys.contrast.userapi\"\x84\x01\n" + + "\ruserapi.proto\x12\x1cedgelesssys.contrast.userapi\"\xa2\x01\n" + "\x12SetManifestRequest\x12\x1a\n" + "\bManifest\x18\x01 \x01(\fR\bManifest\x12\x1a\n" + "\bPolicies\x18\x02 \x03(\fR\bPolicies\x126\n" + - "\x16PreviousTransitionHash\x18\x03 \x01(\fR\x16PreviousTransitionHash\"\x9c\x01\n" + + "\x16PreviousTransitionHash\x18\x03 \x01(\fR\x16PreviousTransitionHash\x12\x1c\n" + + "\tSignature\x18\x04 \x01(\fR\tSignature\"\x9c\x01\n" + "\x13SetManifestResponse\x12\x16\n" + "\x06RootCA\x18\x01 \x01(\fR\x06RootCA\x12\x16\n" + "\x06MeshCA\x18\x02 \x01(\fR\x06MeshCA\x12U\n" + diff --git a/internal/userapi/userapi.proto b/internal/userapi/userapi.proto index 66934a346f7..d30e7479060 100644 --- a/internal/userapi/userapi.proto +++ b/internal/userapi/userapi.proto @@ -14,6 +14,7 @@ message SetManifestRequest { bytes Manifest = 1; repeated bytes Policies = 2; bytes PreviousTransitionHash = 3; + bytes Signature = 4; } message SetManifestResponse {