From 359ab36c88b44c09327f40af711f5817cc6fdefa Mon Sep 17 00:00:00 2001 From: "David Muto (pseudomuto)" Date: Tue, 9 Jun 2026 11:19:15 -0400 Subject: [PATCH] [config]: Add encryption configuration Adds an Encryption section to S2SProxyConfig so the proxy can be configured with KMS-backed key material for payload encryption. The config defines the active KMS key URI, any retired URIs that may still be needed for decryption after a provider migration or key rotation, and the DEK validity/renewal durations that drive rotation behavior. Key URIs follow the [gocloud.dev/secrets] URL scheme so we can transparently support GCP KMS, AWS KMS, and Azure Key Vault without provider-specific config branches. A testing scheme is also accepted for local development and tests where standing up a real KMS isn't practical. Validation happens at YAML unmarshal time so misconfigured URIs fail fast at startup rather than surfacing later when encryption is first exercised. [gocloud.dev/secrets]: https://gocloud.dev/howto/secrets/ --- config/config.go | 13 ++++-- config/encryption.go | 94 +++++++++++++++++++++++++++++++++++++++ config/encryption_test.go | 59 ++++++++++++++++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 config/encryption.go create mode 100644 config/encryption_test.go diff --git a/config/config.go b/config/config.go index edf64c1..da2cda3 100644 --- a/config/config.go +++ b/config/config.go @@ -48,6 +48,7 @@ type ( Logging LoggingConfig `yaml:"logging"` LogConfigs map[string]LoggingConfig `yaml:"logConfigs"` ClusterConnections []ClusterConnConfig `yaml:"clusterConnections"` + Encryption Encryption `yaml:"encryption"` } SATranslationConfig struct { @@ -165,7 +166,7 @@ func WriteConfig[T any](config T, filePath string) error { } // Write the YAML to a file - err = os.WriteFile(filePath, data, 0644) + err = os.WriteFile(filePath, data, 0o644) if err != nil { return err } @@ -179,9 +180,7 @@ type ( } ) -var ( - EmptyConfigProvider MockConfigProvider -) +var EmptyConfigProvider MockConfigProvider func NewMockConfigProvider(config S2SProxyConfig) *MockConfigProvider { return &MockConfigProvider{config: config} @@ -245,6 +244,7 @@ func (s SearchAttributeTranslation) Inverse() SearchAttributeTranslation { inverted: !s.inverted, } } + func (s SearchAttributeTranslation) withInvert(namespace string) (collect.StaticBiMap[string, string], bool) { m, found := s.inner[namespace] if !found { @@ -255,27 +255,32 @@ func (s SearchAttributeTranslation) withInvert(namespace string) (collect.Static } return m, true } + func (s SearchAttributeTranslation) Get(namespace string, searchAttr string) string { if m, ok := s.withInvert(namespace); ok { return m.Get(searchAttr) } return "" } + func (s SearchAttributeTranslation) GetExists(namespace string, searchAttr string) (string, bool) { if m, ok := s.withInvert(namespace); ok { return m.GetExists(searchAttr) } return "", false } + func (s SearchAttributeTranslation) LenNamespaces() int { return len(s.inner) } + func (s SearchAttributeTranslation) Len(namespace string) int { if m, ok := s.withInvert(namespace); ok { return m.Len() } return 0 } + func (s SearchAttributeTranslation) FlattenMaps() map[string]map[string]string { raw := make(map[string]map[string]string, len(s.inner)) for ns, mappings := range s.inner { diff --git a/config/encryption.go b/config/encryption.go new file mode 100644 index 0000000..9e9dd95 --- /dev/null +++ b/config/encryption.go @@ -0,0 +1,94 @@ +package config + +import ( + "fmt" + "net/url" + "slices" + "strings" + "time" +) + +// The set of valid KMS key schemes +var validKeySchemes = []string{ + "awskms", + "azurekeyvault", + "gcpkms", + "testing", +} + +type ( + Encryption struct { + // Enabled determines whether encryption is enabled. Decryption will be attempted for encrypted + // payloads regardless of this flag. + Enabled bool `yaml:"enabled"` + + // Policy defines keys and rotation policies. + Policy KeyPolicy `yaml:"policy"` + } + + KeyPolicy struct { + // URI is the vendor-specific URL identifying the KMS key used to encrypt DEKs. + // URIs follow the gocloud.dev/secrets URL scheme (with the exception of the testing scheme): + // + // GCP KMS: gcpkms://projects/PROJECT/locations/LOCATION/keyRings/RING/cryptoKeys/KEY + // AWS KMS: awskms:///arn:aws:kms:REGION:ACCOUNT:key/KEY-ID?region=REGION + // Azure Vault: azurekeyvault://VAULT.vault.azure.net/keys/KEY-NAME/KEY-VERSION + // Local/test: testing://smGbjm71Nxd1Ig5FS0wj9SlbzAIrnolCz9bQQ6uAhl4= + URI string `yaml:"uri"` + + // RetiredURIs lists KMS keys that are no longer used to encrypt new DEKs but + // are still needed to decrypt DEKs encrypted by previous keys (e.g. after a + // provider migration). Each entry follows the same URI scheme rules as [URI]. + RetiredURIs []string `yaml:"retiredURIs"` + + // Duration is how long the DEK is valid before it must be rotated. + Duration time.Duration `yaml:"duration"` + + // RenewBefore is how far before a DEK expires it should be proactively rotated. + RenewBefore time.Duration `yaml:"renewBefore"` + } +) + +func (p *KeyPolicy) UnmarshalYAML(unmarshal func(any) error) error { + type raw KeyPolicy + var decoded raw + if err := unmarshal(&decoded); err != nil { + return err + } + + *p = KeyPolicy(decoded) + return p.validURIs() +} + +func (p *KeyPolicy) validURIs() error { + if err := validKeyURI(p.URI); err != nil { + if strings.TrimSpace(p.URI) != "" { + return err + } + } + + for _, uri := range p.RetiredURIs { + if err := validKeyURI(uri); err != nil { + return err + } + } + + return nil +} + +func validKeyURI(uri string) error { + u, err := url.Parse(uri) + if err != nil { + return fmt.Errorf("failed to parse key URI: %s, %w", uri, err) + } + + if !slices.Contains(validKeySchemes, strings.ToLower(u.Scheme)) { + return fmt.Errorf( + "invalid key URI: %s, valid schemes: [%s]", + uri, + strings.Join(validKeySchemes, ","), + ) + } + + return nil +} diff --git a/config/encryption_test.go b/config/encryption_test.go new file mode 100644 index 0000000..9e6f7d4 --- /dev/null +++ b/config/encryption_test.go @@ -0,0 +1,59 @@ +package config_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/temporalio/s2s-proxy/config" +) + +func TestKeyPolicyURIs(t *testing.T) { + tests := []struct { + name string + uri string + wantErr bool + }{ + {name: "empty"}, + {name: "gcpkms", uri: "gcpkms://projects/p/locations/global/keyRings/r/cryptoKeys/k"}, + {name: "awskms", uri: "awskms:///arn:aws:kms:us-east-1:123456789012:key/abc?region=us-east-1"}, + {name: "azurekeyvault", uri: "azurekeyvault://my-vault.vault.azure.net/keys/my-key/v1"}, + {name: "testing", uri: "testing://smGbjm71Nxd1Ig5FS0wj9SlbzAIrnolCz9bQQ6uAhl4="}, + {name: "unknown scheme", uri: "hashivault://localhost/v1/transit/keys/my-key", wantErr: true}, + {name: "unparseable URI", uri: "://bad", wantErr: true}, + } + + for _, tt := range tests { + str := "policy:\n uri: " + tt.uri + + var enc config.Encryption + err := yaml.Unmarshal([]byte(str), &enc) + if tt.wantErr { + require.Error(t, err, tt.name) + continue + } + + require.NoError(t, err, tt.name) + } + + // Verify retired URIs are also validated + for _, tt := range tests { + tt.name += " retired" + if tt.uri == "" { + tt.wantErr = true + } + + str := fmt.Sprintf("policy:\n retiredURIs:\n - \"%s\"", tt.uri) + + var enc config.Encryption + err := yaml.Unmarshal([]byte(str), &enc) + if tt.wantErr { + require.Error(t, err, tt.name) + continue + } + + require.NoError(t, err, tt.name) + } +}