diff --git a/README.md b/README.md index cb273da..e56e8e7 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,12 @@ Images are published to [Docker Hub](https://hub.docker.com/r/temporalio/tempora docker pull temporalio/temporal-proxy:latest ``` +To guard against a deployment that would forward payloads unencrypted, start the proxy with +`--require-encryption` (or set `PROXY_REQUIRE_ENCRYPTION=true`). The proxy then refuses to start unless the config +has an encryption key (`encryption.default`). It checks for a key rather than `encryption.enabled`, so a decrypt-only +proxy (a key with `enabled: false`) still starts. The flag sits outside the config file so that editing the config +alone cannot turn it off. + ### Helm A chart is published to the Temporal Helm repo at `https://go.temporal.io/helm-charts`: diff --git a/cmd/proxy/serve.go b/cmd/proxy/serve.go index e6ac34f..441f462 100644 --- a/cmd/proxy/serve.go +++ b/cmd/proxy/serve.go @@ -58,6 +58,11 @@ func serve() *cli.Command { Value: "info", Sources: cli.EnvVars("LOG_LEVEL"), }, + &cli.BoolFlag{ + Name: "require-encryption", + Usage: "Refuse to start unless the config has an encryption key (encryption.default)", + Sources: cli.EnvVars("PROXY_REQUIRE_ENCRYPTION"), + }, }, Action: func(ctx context.Context, cmd *cli.Command) error { log := logger.NewZeroLogger(os.Stderr, logger.ParseLevel(cmd.String("level"))) @@ -81,6 +86,7 @@ func serve() *cli.Command { auth.Module, codecserver.Module, config.Module, + config.RequireKey(cmd.Bool("require-encryption")), connect.Module, dataplane.Module, httpserver.Module, diff --git a/internal/config/encryption.go b/internal/config/encryption.go index aebaba9..1987243 100644 --- a/internal/config/encryption.go +++ b/internal/config/encryption.go @@ -138,6 +138,21 @@ func (e *Encryption) Validate() error { return validation.Validate("", rules...) } +// RequireKey fails unless a Default key policy is configured, whether or not +// encryption is Enabled. +func (e *Encryption) RequireKey() error { + return validation.Validate( + "encryption", + validation.Field("default", e.Default, func(p *KeyPolicy) error { + if p == nil { + return errors.New("a key is required when encryption is required") + } + + return nil + }), + ) +} + // Mode maps b to the vault's binding mode. An absent value is an error rather // than a default, since the right mode depends on whether earlier payloads exist. // It is the only place the spellings are known, so validation and vault diff --git a/internal/config/fx.go b/internal/config/fx.go index 5bc579f..ec84637 100644 --- a/internal/config/fx.go +++ b/internal/config/fx.go @@ -38,3 +38,16 @@ func Supply(cfg *Config) fx.Option { NewAllowlist, ) } + +// RequireKey fails the graph unless the provided *Config has an encryption key +// (see [Encryption.RequireKey]). When required is false it adds nothing. It +// works with either [Module] or [Supply]. +func RequireKey(required bool) fx.Option { + if !required { + return fx.Options() + } + + return fx.Invoke(func(cfg *Config) error { + return cfg.Encryption.RequireKey() + }) +} diff --git a/internal/config/fx_test.go b/internal/config/fx_test.go index 61cc1bf..347e52b 100644 --- a/internal/config/fx_test.go +++ b/internal/config/fx_test.go @@ -135,3 +135,49 @@ func TestModuleRejectsInvalidFile(t *testing.T) { ) require.ErrorContains(t, app.Err(), "at least one upstream is required") } + +// TestRequireKey covers the startup guard against a proxy that would forward +// plaintext. It keys on a configured key, not on enabled, so a decrypt-only +// proxy (a key with enabled false) still starts. +func TestRequireKey(t *testing.T) { + t.Parallel() + + const key = " namespaceBinding: strict\n default:\n uri: testing://a2V5\n duration: 1h\n renewBefore: 10m\n" + + tests := []struct { + name string + require bool + yaml string + wantErr bool + }{ + {name: "no encryption block", require: true, yaml: "", wantErr: true}, + {name: "disabled without a key", require: true, yaml: "encryption:\n enabled: false\n", wantErr: true}, + {name: "decrypt-only with a key", require: true, yaml: "encryption:\n enabled: false\n" + key}, + {name: "enabled with a key", require: true, yaml: "encryption:\n enabled: true\n" + key}, + {name: "not required and no key", require: false, yaml: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("hostPort: :7233\n"+upstreamYAML+tt.yaml), 0o600)) + + app := fx.New( + fx.NopLogger, + fx.Supply(fx.Annotate(path, config.ConfigFileTag)), + config.Module, + config.RequireKey(tt.require), + fx.Invoke(func(*config.Config) {}), + ) + + if tt.wantErr { + require.ErrorContains(t, app.Err(), "encryption: default: a key is required when encryption is required") + return + } + + require.NoError(t, app.Err()) + }) + } +}