Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cli/cmd/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
rulesFilename = "rules.rego"
layersCacheFilename = "layers-cache.json"
latestTransitionHashFilename = "latest-transition"
nextTransitionHashFilename = "next-transition"
historyFilename = "history.yml"
verifyDir = "verify"
)
Expand Down
39 changes: 35 additions & 4 deletions cli/cmd/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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) {
Expand All @@ -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))
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to write to a file here or to stdout? I've written it to a file for now since we compute the signature over a file when using openssl.

return nil
}

kdsGetter, err := cachedHTTPSGetter(log)
if err != nil {
return fmt.Errorf("configuring KDS cache: %w", err)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -204,6 +227,8 @@ type setFlags struct {
workloadOwnerKeyPath string
atomic bool
latestTransition string
dryRun bool
signaturePath string
workspaceDir string
}

Expand Down Expand Up @@ -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)
Expand Down
42 changes: 39 additions & 3 deletions coordinator/internal/userapi/userapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"crypto/ecdsa"
"crypto/rsa"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
)
29 changes: 29 additions & 0 deletions coordinator/internal/userapi/userapi_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions docs/docs/howto/manifest-update.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <workload-owner-key> -out transition.sig next-transition
contrast set -c "${coordinator}:1313" -s transition.sig resources/
```
42 changes: 42 additions & 0 deletions e2e/coordinator/coordinator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ package coordinator

import (
"context"
"crypto/ecdsa"
"crypto/rand"
"crypto/sha256"
"flag"
"os"
"path/filepath"
Expand Down Expand Up @@ -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) {
Expand Down
13 changes: 11 additions & 2 deletions internal/userapi/userapi.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/userapi/userapi.proto
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ message SetManifestRequest {
bytes Manifest = 1;
repeated bytes Policies = 2;
bytes PreviousTransitionHash = 3;
bytes Signature = 4;
}

message SetManifestResponse {
Expand Down
Loading