From 56c8a72c82cd7d9e629c40fd6a8e12c9d546b858 Mon Sep 17 00:00:00 2001 From: davidweisse <98460960+davidweisse@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:46:31 +0200 Subject: [PATCH 1/4] cli: add sign subcommand --- cli/cmd/sign.go | 173 ++++++++++++++++++++++++++++++++++++++++++++++++ cli/main.go | 1 + 2 files changed, 174 insertions(+) create mode 100644 cli/cmd/sign.go diff --git a/cli/cmd/sign.go b/cli/cmd/sign.go new file mode 100644 index 00000000000..927adc2ddc0 --- /dev/null +++ b/cli/cmd/sign.go @@ -0,0 +1,173 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + "github.com/edgelesssys/contrast/internal/history" + "github.com/edgelesssys/contrast/internal/manifest" + "github.com/spf13/cobra" +) + +// NewSignCmd creates the contrast sign subcommand. +func NewSignCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "sign [flags]", + Short: "Sign the given manifest and transition hash", + Long: `Sign the given manifest and transition hash. + +This will read the manifest and previous transition hash and sign them using +the provided workload owner key. The resulting signature can be used for a +signed manifest update at the Coordinator without providing the workload owner +key to the CLI. + +Using the prepare flag, the CLI will compute the next transition hash and +output it to a file so it can be signed using an external tool like an HSM.`, + RunE: withTelemetry(runSign), + } + cmd.SetOut(commandOut()) + + cmd.Flags().StringP("manifest", "m", manifestFilename, "path to manifest (.json) file") + cmd.Flags().String("workload-owner-key", workloadOwnerPEM, "path to workload owner key (.pem) file") + cmd.Flags().String("latest-transition", "", "latest transition hash set at the coordinator (hex string)") + cmd.Flags().Bool("prepare", false, "prepare the next transition hash for signing without signing it") + cmd.Flags().String("out", "", "output file for the signature (or next transition hash when using --prepare)") + must(cmd.MarkFlagRequired("out")) + must(cmd.MarkFlagFilename("manifest", "json")) + + return cmd +} + +func runSign(cmd *cobra.Command, _ []string) error { + flags, err := parseSignFlags(cmd) + if err != nil { + return fmt.Errorf("failed to parse flags: %w", err) + } + + log, err := newCLILogger(cmd) + if err != nil { + return err + } + + manifestBytes, err := os.ReadFile(flags.manifestPath) + 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) + } + + if flags.latestTransition == "" { + data, err := os.ReadFile(filepath.Join(flags.workspaceDir, verifyDir, latestTransitionHashFilename)) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("reading previous transition hash: %w", err) + } else if errors.Is(err, os.ErrNotExist) { + data = []byte(strings.Repeat("00", history.HashSize)) // Assume initial set manifest + } + flags.latestTransition = string(data) + } + previousTransitionHash, err := hex.DecodeString(flags.latestTransition) + if err != nil { + return fmt.Errorf("decoding latest transition hash: %w", err) + } + + tr := &history.Transition{ + ManifestHash: history.Digest(manifestBytes), + PreviousTransitionHash: [history.HashSize]byte(previousTransitionHash), + } + transitionHash := tr.Digest() + transitionHashHex := hex.AppendEncode(nil, transitionHash[:]) + + if flags.prepare { + if err := os.WriteFile(flags.out, transitionHashHex, 0o644); err != nil { + return fmt.Errorf("writing next transition hash to file: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Next transition hash written to %s.\n", flags.out) + return nil + } + + workloadOwnerKey, err := loadWorkloadOwnerKey(flags.workloadOwnerKeyPath, &m, log) + if err != nil { + return fmt.Errorf("loading workload owner key: %w", err) + } + + signingHash := sha256.Sum256(transitionHashHex) + sig, err := ecdsa.SignASN1(rand.Reader, workloadOwnerKey, signingHash[:]) + if err != nil { + return fmt.Errorf("signing transition hash: %w", err) + } + + if err := os.WriteFile(flags.out, sig, 0o644); err != nil { + return fmt.Errorf("writing signature to file: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Transition hash signed and signature written to %s.\n", flags.out) + + return nil +} + +type signFlags struct { + manifestPath string + workloadOwnerKeyPath string + latestTransition string + prepare bool + out string + workspaceDir string +} + +func parseSignFlags(cmd *cobra.Command) (*signFlags, error) { + flags := &signFlags{} + var err error + + flags.manifestPath, err = cmd.Flags().GetString("manifest") + if err != nil { + return nil, fmt.Errorf("failed to get manifest flag: %w", err) + } + flags.workloadOwnerKeyPath, err = cmd.Flags().GetString("workload-owner-key") + if err != nil { + return nil, fmt.Errorf("getting workload-owner-key flag: %w", err) + } + flags.latestTransition, err = cmd.Flags().GetString("latest-transition") + if err != nil { + return nil, fmt.Errorf("getting latest-transition flag: %w", err) + } + flags.prepare, err = cmd.Flags().GetBool("prepare") + if err != nil { + return nil, fmt.Errorf("getting prepare flag: %w", err) + } + flags.out, err = cmd.Flags().GetString("out") + if err != nil { + return nil, fmt.Errorf("getting dry-run flag: %w", err) + } + flags.workspaceDir, err = cmd.Flags().GetString("workspace-dir") + if err != nil { + return nil, fmt.Errorf("getting workspace-dir flag: %w", err) + } + + if flags.workspaceDir != "" { + // Prepend default paths with workspaceDir + if !cmd.Flags().Changed("manifest") { + flags.manifestPath = path.Join(flags.workspaceDir, flags.manifestPath) + } + if !cmd.Flags().Changed("workload-owner-key") { + flags.workloadOwnerKeyPath = path.Join(flags.workspaceDir, flags.workloadOwnerKeyPath) + } + } + + return flags, nil +} diff --git a/cli/main.go b/cli/main.go index 08a76349068..46137e8c94e 100644 --- a/cli/main.go +++ b/cli/main.go @@ -122,6 +122,7 @@ func newRootCmd() (*cobra.Command, error) { cmd.NewSetCmd(), cmd.NewVerifyCmd(), cmd.NewRecoverCmd(), + cmd.NewSignCmd(), ) return root, nil From da442274114a5d3f0ce68a70af03da5e46f48cee Mon Sep 17 00:00:00 2001 From: davidweisse <98460960+davidweisse@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:46:51 +0200 Subject: [PATCH 2/4] cli: allow signed manifest updates --- cli/cmd/set.go | 20 ++++++-- coordinator/internal/userapi/userapi.go | 48 ++++++++++++++++++-- coordinator/internal/userapi/userapi_test.go | 40 ++++++++++++++++ internal/userapi/userapi.pb.go | 13 +++++- internal/userapi/userapi.proto | 1 + 5 files changed, 114 insertions(+), 8 deletions(-) diff --git a/cli/cmd/set.go b/cli/cmd/set.go index c226aef2470..cf85a0d8986 100644 --- a/cli/cmd/set.go +++ b/cli/cmd/set.go @@ -58,6 +58,7 @@ issuer certificates.`, 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 +92,13 @@ func runSet(cmd *cobra.Command, args []string) error { } else if err != nil { return fmt.Errorf("loading workload owner key: %w", err) } + var signatureBytes []byte + if flags.signaturePath != "" { + signatureBytes, err = os.ReadFile(flags.signaturePath) + if err != nil { + return fmt.Errorf("reading signature file: %w", err) + } + } paths, err := findYamlFiles(args) if err != nil { @@ -153,6 +161,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 { @@ -160,7 +169,9 @@ func runSet(cmd *cobra.Command, args []string) error { if ok { if grpcSt.Code() == codes.PermissionDenied { msg := "Permission denied." - if workloadOwnerKey == nil { + if signatureBytes != nil { + msg += " Ensure the signature is valid and corresponds to the latest transition hash." + } else if workloadOwnerKey == nil { msg += " Specify a workload owner key with --workload-owner-key." } else { msg += " Ensure you are using a trusted workload owner key." @@ -204,6 +215,7 @@ type setFlags struct { workloadOwnerKeyPath string atomic bool latestTransition string + signaturePath string workspaceDir string } @@ -231,11 +243,13 @@ 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.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..f575a3f600c 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 { @@ -110,6 +116,12 @@ func (s *Server) SetManifest(ctx context.Context, req *userapi.SetManifestReques } } else { // First SetManifest call, initialize seed engine. + if req.Signature != nil { + if err := validateSignature(m.WorkloadOwnerPubKeys, [history.HashSize]byte{}, req); err != nil { + s.logger.Warn("SetManifest signature validation failed for initial manifest", "err", err) + return nil, status.Errorf(codes.PermissionDenied, "validating manifest signature: %v", err) + } + } if req.GetPreviousTransitionHash() != nil && !bytes.Equal(req.GetPreviousTransitionHash(), make([]byte, history.HashSize)) { return nil, status.Errorf(codes.FailedPrecondition, "previous transition hash '%x' requested but manifest history is empty", req.GetPreviousTransitionHash()) } @@ -246,6 +258,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 +337,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 = errors.New("manifest signature is empty") ) diff --git a/coordinator/internal/userapi/userapi_test.go b/coordinator/internal/userapi/userapi_test.go index fd113b425fd..48bc8cbd245 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,42 @@ 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(), nil) + m, err := json.Marshal(manifestWithTrustedKey) + require.NoError(err) + tr := history.Transition{ + ManifestHash: history.Digest(m), + } + nextTransitionHash := tr.Digest() + signBlob := history.Digest(hex.AppendEncode(nil, nextTransitionHash[:])) + sig, err := ecdsa.SignASN1(rand.Reader, trustedKey, signBlob[:]) + require.NoError(err) + req := &userapi.SetManifestRequest{ + Manifest: m, + Signature: sig, + } + _, err = coordinator.SetManifest(ctx, req) + require.NoError(err) + tr = history.Transition{ + ManifestHash: history.Digest(m), + PreviousTransitionHash: nextTransitionHash, + } + nextTransitionHash = tr.Digest() + signBlob = history.Digest(hex.AppendEncode(nil, nextTransitionHash[:])) + sig, err = ecdsa.SignASN1(rand.Reader, trustedKey, signBlob[:]) + require.NoError(err) + req = &userapi.SetManifestRequest{ + Manifest: m, + Signature: sig, + } + _, err = coordinator.SetManifest(ctx, req) + require.NoError(err) + }) } func TestGetManifests(t *testing.T) { @@ -890,6 +928,8 @@ func rpcContext(ctx context.Context, cryptoKey crypto.PrivateKey) context.Contex if key != nil { peerCertificates = append(peerCertificates, &x509.Certificate{PublicKey: key.Public(), PublicKeyAlgorithm: x509.ECDSA}) } + case nil: + // No key, no certificates. default: panic(fmt.Sprintf("unsupported key type for rpcContext: %T", cryptoKey)) } 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 { From b103d1851f35bd8f821af48b62b40f8c7a2a90a8 Mon Sep 17 00:00:00 2001 From: davidweisse <98460960+davidweisse@users.noreply.github.com> Date: Wed, 1 Apr 2026 14:57:29 +0200 Subject: [PATCH 3/4] e2e: test signed manifest updates --- e2e/coordinator/coordinator_test.go | 65 +++++++++++++++++++++++ e2e/internal/contrasttest/contrasttest.go | 16 ++++++ packages/by-name/contrast/e2e/package.nix | 8 +++ 3 files changed, 89 insertions(+) diff --git a/e2e/coordinator/coordinator_test.go b/e2e/coordinator/coordinator_test.go index 254d56cd7d8..ef553d9d3e0 100644 --- a/e2e/coordinator/coordinator_test.go +++ b/e2e/coordinator/coordinator_test.go @@ -7,8 +7,12 @@ package coordinator import ( "context" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" "flag" "os" + "os/exec" "path/filepath" "testing" "time" @@ -107,6 +111,67 @@ 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") + + ctx, cancel := context.WithTimeout(t.Context(), ct.FactorPlatformTimeout(2*time.Minute)) + t.Cleanup(cancel) + + // Initial signed manifest update with a valid signature. + require.NoError(ct.RunSign(ctx, "--out", filepath.Join(ct.WorkDir, "transition.sig"))) + + keyBytes, err := os.ReadFile(filepath.Join(ct.WorkDir, "workload-owner.pem")) + require.NoError(err) + key, err := manifest.ParseWorkloadOwnerPrivateKey(keyBytes) + require.NoError(err) + // Rename the key so that it is not passed to the CLI. + require.NoError(os.Rename(filepath.Join(ct.WorkDir, "workload-owner.pem"), filepath.Join(ct.WorkDir, "key.pem"))) + + require.NoError(ct.RunSet(ctx, "--signature", filepath.Join(ct.WorkDir, "transition.sig"))) + + require.True(t.Run("verify", ct.Verify), "contrast verify needs to succeed for subsequent tests") + + // Manifest update without workload owner key or signature should fail. + require.ErrorContains(ct.RunSet(ctx), "peer not authorized") + + // Signed manifest update with a valid signature computed with Go. + require.NoError(ct.RunSign(ctx, "--prepare", "--out", filepath.Join(ct.WorkDir, "next-transition"))) + + transitionHash, err := os.ReadFile(filepath.Join(ct.WorkDir, "next-transition")) + 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)) + + require.NoError(ct.RunSet(ctx, "--signature", filepath.Join(ct.WorkDir, "transition.sig"))) + + require.True(t.Run("verify", ct.Verify), "contrast verify needs to succeed for subsequent tests") + + // Signed manifest update with a valid signature computed with OpenSSL. + require.NoError(ct.RunSign(ctx, "--prepare", "--out", filepath.Join(ct.WorkDir, "next-transition"))) + + opensslCmd := exec.CommandContext(ctx, "openssl", "dgst", "-sha256", "-sign", filepath.Join(ct.WorkDir, "key.pem"), "-out", filepath.Join(ct.WorkDir, "transition.sig"), filepath.Join(ct.WorkDir, "next-transition")) + require.NoError(opensslCmd.Run()) + + whichCmd := exec.CommandContext(ctx, "which", "openssl") + out, err := whichCmd.Output() + require.NoError(err) + t.Logf("Using OpenSSL from: %s", string(out)) + + require.NoError(ct.RunSet(ctx, "--signature", filepath.Join(ct.WorkDir, "transition.sig"))) + }) } func TestMain(m *testing.M) { diff --git a/e2e/internal/contrasttest/contrasttest.go b/e2e/internal/contrasttest/contrasttest.go index 9589173acec..fd03efd368a 100644 --- a/e2e/internal/contrasttest/contrasttest.go +++ b/e2e/internal/contrasttest/contrasttest.go @@ -439,6 +439,22 @@ func (ct *ContrastTest) RunRecover(ctx context.Context) error { return ct.runAgainstCoordinator(ctx, cmd.NewRecoverCmd()) } +// RunSign runs the contrast sign subcommand. +func (ct *ContrastTest) RunSign(ctx context.Context, args ...string) error { + cmd := cmd.NewSignCmd() + cmd.Flags().String("workspace-dir", "", "") + cmd.Flags().String("log-level", "debug", "") + cmd.SetArgs(append(ct.commonArgs(), args...)) + cmd.SetOut(io.Discard) + errBuf := &bytes.Buffer{} + cmd.SetErr(errBuf) + + if err := cmd.ExecuteContext(ctx); err != nil { + return fmt.Errorf("running %q: %s", cmd.Use, errBuf) + } + return nil +} + // MeshCACert returns a CertPool that contains the coordinator mesh CA cert. func (ct *ContrastTest) MeshCACert() *x509.CertPool { pool := x509.NewCertPool() diff --git a/packages/by-name/contrast/e2e/package.nix b/packages/by-name/contrast/e2e/package.nix index 96c64502370..e224a51032d 100644 --- a/packages/by-name/contrast/e2e/package.nix +++ b/packages/by-name/contrast/e2e/package.nix @@ -6,6 +6,8 @@ buildGoModule, contrast, cli, + makeWrapper, + openssl, }: buildGoModule { @@ -88,6 +90,12 @@ buildGoModule { # keep-sorted end ]; + nativeBuildInputs = [ makeWrapper ]; + + postInstall = '' + wrapProgram "$out/bin/coordinator.test" --prefix PATH : "${openssl}/bin" + ''; + # Skip fixup as binaries are already stripped and we don't # need any other fixup, saving some seconds. dontFixup = true; From 8dc08cf811b3b4eca32e812f5a91d36eece190f1 Mon Sep 17 00:00:00 2001 From: davidweisse <98460960+davidweisse@users.noreply.github.com> Date: Mon, 20 Apr 2026 12:59:54 +0200 Subject: [PATCH 4/4] docs: document signed manifest updates --- docs/docs/howto/manifest-update.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/docs/howto/manifest-update.md b/docs/docs/howto/manifest-update.md index 704d8af7d55..1d496330b97 100644 --- a/docs/docs/howto/manifest-update.md +++ b/docs/docs/howto/manifest-update.md @@ -64,3 +64,31 @@ Optionally, you can specify a transition hash using the `--latest-transition` fl ```sh contrast set -c "${coordinator}:1313" --atomic --latest-transition ab...cd resources/ ``` + +### 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 `contrast sign` subcommand with the `--prepare` flag to get the blob that needs to be signed: + +```sh +contrast sign --prepare --out next-transition +``` + +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/ +``` + +If you have direct access to the workload owner key, you can also sign the manifest update using the CLI: + +```sh +contrast sign --out transition.sig +contrast set -c "${coordinator}:1313" -s transition.sig resources/ +```