diff --git a/pkg/api/config.go b/pkg/api/config.go index 0e731081..86fc0945 100644 --- a/pkg/api/config.go +++ b/pkg/api/config.go @@ -109,6 +109,21 @@ type S3Credentials struct { // +optional SessionToken *machineryapi.SecretKeySelector `json:"sessionToken,omitempty"` + // The reference to the secret containing the key for + // Server-Side Encryption with Customer-provided keys (SSE-C). + // When set, every object barman-cloud uploads to and downloads from + // S3 is encrypted with this key using the AWS SSE-C protocol + // (the `--sse-customer-key` barman-cloud option). + // The referenced value must be a base64-encoded 256-bit (32-byte) + // AES key. This is meant for S3-compatible providers that only + // support customer-provided keys (e.g. Hetzner Object Storage) and + // cannot be combined with the bucket-managed `encryption` field + // (SSE-S3/SSE-KMS), which barman-cloud rejects together with SSE-C. + // It can be combined with any authentication method, including + // inheritFromIAMRole. + // +optional + SSECustomerKey *machineryapi.SecretKeySelector `json:"sseCustomerKey,omitempty"` + // Use the role based authentication without providing explicitly the keys. // +optional InheritFromIAMRole bool `json:"inheritFromIAMRole,omitempty"` diff --git a/pkg/api/webhooks/config.go b/pkg/api/webhooks/config.go index 7300e63f..a9d8e529 100644 --- a/pkg/api/webhooks/config.go +++ b/pkg/api/webhooks/config.go @@ -72,6 +72,38 @@ func ValidateBackupConfiguration( )) } + allErrors = append(allErrors, validateSSECustomerKey(barmanObjectStore, path)...) + + return allErrors +} + +// validateSSECustomerKey checks that SSE-C is not combined with the other +// server-side encryption modes, which barman-cloud rejects +func validateSSECustomerKey( + barmanObjectStore *api.BarmanObjectStoreConfiguration, + path *field.Path, +) field.ErrorList { + if barmanObjectStore.AWS == nil || barmanObjectStore.AWS.SSECustomerKey == nil { + return nil + } + + const message = "cannot be used together with s3Credentials.sseCustomerKey" + allErrors := field.ErrorList{} + if barmanObjectStore.Data != nil && barmanObjectStore.Data.Encryption != "" { + allErrors = append(allErrors, field.Invalid( + path.Child("data", "encryption"), + barmanObjectStore.Data.Encryption, + message, + )) + } + if barmanObjectStore.Wal != nil && barmanObjectStore.Wal.Encryption != "" { + allErrors = append(allErrors, field.Invalid( + path.Child("wal", "encryption"), + barmanObjectStore.Wal.Encryption, + message, + )) + } + return allErrors } diff --git a/pkg/api/webhooks/config_test.go b/pkg/api/webhooks/config_test.go index edf5e25c..8aa70ef6 100644 --- a/pkg/api/webhooks/config_test.go +++ b/pkg/api/webhooks/config_test.go @@ -20,6 +20,7 @@ SPDX-License-Identifier: Apache-2.0 package webhooks import ( + machineryapi "github.com/cloudnative-pg/machinery/pkg/api" "k8s.io/apimachinery/pkg/util/validation/field" api "github.com/cloudnative-pg/barman-cloud/pkg/api" @@ -40,6 +41,37 @@ var _ = Describe("Backup validation", func() { err := ValidateBackupConfiguration(nil, nil) Expect(err).To(BeEmpty()) }) + + Context("with an SSE-C customer key", func() { + var configuration *api.BarmanObjectStoreConfiguration + BeforeEach(func() { + configuration = &api.BarmanObjectStoreConfiguration{ + BarmanCredentials: api.BarmanCredentials{ + AWS: &api.S3Credentials{ + InheritFromIAMRole: true, + SSECustomerKey: &machineryapi.SecretKeySelector{ + LocalObjectReference: machineryapi.LocalObjectReference{Name: "backup-keys"}, + Key: "sse-c", + }, + }, + }, + } + }) + + It("accepts it on its own", func() { + err := ValidateBackupConfiguration(configuration, field.NewPath("spec")) + Expect(err).To(BeEmpty()) + }) + + It("rejects it together with data or wal encryption", func() { + configuration.Data = &api.DataBackupConfiguration{Encryption: api.EncryptionTypeAES256} + configuration.Wal = &api.WalBackupConfiguration{Encryption: api.EncryptionTypeAES256} + err := ValidateBackupConfiguration(configuration, field.NewPath("spec")) + Expect(err).To(HaveLen(2)) + Expect(err[0].Field).To(Equal("spec.data.encryption")) + Expect(err[1].Field).To(Equal("spec.wal.encryption")) + }) + }) }) var _ = Describe("Retention Policy Validation", func() { diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index 431bc60b..cdc8c889 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -210,6 +210,11 @@ func (in *S3Credentials) DeepCopyInto(out *S3Credentials) { *out = new(pkgapi.SecretKeySelector) **out = **in } + if in.SSECustomerKey != nil { + in, out := &in.SSECustomerKey, &out.SSECustomerKey + *out = new(pkgapi.SecretKeySelector) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new S3Credentials. diff --git a/pkg/archiver/archiver.go b/pkg/archiver/archiver.go index 18e48155..d6b3bd9e 100644 --- a/pkg/archiver/archiver.go +++ b/pkg/archiver/archiver.go @@ -23,6 +23,7 @@ package archiver import ( "context" "fmt" + "slices" "time" "github.com/cloudnative-pg/machinery/pkg/log" @@ -154,6 +155,12 @@ func (archiver *WALArchiver) BarmanCloudCheckWalArchiveOptions( return nil, err } + // barman-cloud-check-wal-archive only lists the objects in the bucket: + // it does not need the SSE-C customer key, and it rejects the option + if i := slices.Index(options, "--sse-customer-key"); i >= 0 { + options = slices.Delete(options, i, i+2) + } + serverName := clusterName if len(configuration.ServerName) != 0 { serverName = configuration.ServerName diff --git a/pkg/archiver/command_test.go b/pkg/archiver/command_test.go index d8df9c33..048a425f 100644 --- a/pkg/archiver/command_test.go +++ b/pkg/archiver/command_test.go @@ -23,6 +23,8 @@ import ( "os" "strings" + machineryapi "github.com/cloudnative-pg/machinery/pkg/api" + barmanApi "github.com/cloudnative-pg/barman-cloud/pkg/api" . "github.com/onsi/ginkgo/v2" @@ -147,3 +149,28 @@ var _ = Describe("barmanCloudWalArchiveOptions", func() { )) }) }) + +var _ = Describe("BarmanCloudCheckWalArchiveOptions", func() { + It("does not pass the SSE-C customer key, which the command rejects", func(ctx SpecContext) { + config := &barmanApi.BarmanObjectStoreConfiguration{ + DestinationPath: "s3://bucket-name/", + EndpointURL: "http://s3:9000", + BarmanCredentials: barmanApi.BarmanCredentials{ + AWS: &barmanApi.S3Credentials{ + InheritFromIAMRole: true, + SSECustomerKey: &machineryapi.SecretKeySelector{ + LocalObjectReference: machineryapi.LocalObjectReference{Name: "sse-c"}, + Key: "key", + }, + }, + }, + } + options, err := (&WALArchiver{}).BarmanCloudCheckWalArchiveOptions(ctx, config, "test-cluster") + Expect(err).ToNot(HaveOccurred()) + Expect(options).To(Equal([]string{ + "--endpoint-url", "http://s3:9000", + "--cloud-provider", "aws-s3", + "s3://bucket-name/", "test-cluster", + })) + }) +}) diff --git a/pkg/command/commandbuilder.go b/pkg/command/commandbuilder.go index 70260b0b..e7545db6 100644 --- a/pkg/command/commandbuilder.go +++ b/pkg/command/commandbuilder.go @@ -23,6 +23,7 @@ import ( "context" barmanApi "github.com/cloudnative-pg/barman-cloud/pkg/api" + "github.com/cloudnative-pg/barman-cloud/pkg/utils" ) // CloudWalRestoreOptions returns the options needed to execute the barman command successfully @@ -87,6 +88,16 @@ func appendCloudProviderOptions( options, "--cloud-provider", "aws-s3") + + // When Server-Side Encryption with Customer-provided keys (SSE-C) + // is configured, point barman-cloud at the key file that the + // credentials package materializes from the referenced secret. + if credentials.AWS.SSECustomerKey != nil { + options = append( + options, + "--sse-customer-key", + "file://"+utils.SSECustomerKeyFilePath(credentials.AWS.SSECustomerKey)) + } case credentials.Azure != nil: options = append( options, diff --git a/pkg/command/commandbuilder_test.go b/pkg/command/commandbuilder_test.go index bb2d1a0d..425e7a6c 100644 --- a/pkg/command/commandbuilder_test.go +++ b/pkg/command/commandbuilder_test.go @@ -172,3 +172,67 @@ var _ = Describe("AppendCloudProviderOptions with Azure credentials", func() { )) }) }) + +var _ = Describe("AppendCloudProviderOptions with AWS credentials", func() { + var options []string + + BeforeEach(func() { + options = []string{} + }) + + It("should not add the SSE-C option when no customer key is set", func(ctx SpecContext) { + credentials := barmanApi.BarmanCredentials{ + AWS: &barmanApi.S3Credentials{ + InheritFromIAMRole: true, + }, + } + result, err := appendCloudProviderOptions(ctx, options, credentials) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal([]string{ + "--cloud-provider", "aws-s3", + })) + Expect(result).ToNot(ContainElement("--sse-customer-key")) + }) + + It("should add the SSE-C option pointing at the key file when a customer key is set", func(ctx SpecContext) { + credentials := barmanApi.BarmanCredentials{ + AWS: &barmanApi.S3Credentials{ + InheritFromIAMRole: true, + SSECustomerKey: &machineryapi.SecretKeySelector{ + LocalObjectReference: machineryapi.LocalObjectReference{ + Name: "sse-c-key", + }, + Key: "key", + }, + }, + } + result, err := appendCloudProviderOptions(ctx, options, credentials) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(Equal([]string{ + "--cloud-provider", "aws-s3", + "--sse-customer-key", "file:///controller/.sse-customer-keys/sse-c-key/key", + })) + }) + + It("should point object stores with different customer keys at different files", func(ctx SpecContext) { + keyFileOption := func(secretName string) string { + credentials := barmanApi.BarmanCredentials{ + AWS: &barmanApi.S3Credentials{ + InheritFromIAMRole: true, + SSECustomerKey: &machineryapi.SecretKeySelector{ + LocalObjectReference: machineryapi.LocalObjectReference{ + Name: secretName, + }, + Key: "key", + }, + }, + } + result, err := appendCloudProviderOptions(ctx, []string{}, credentials) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(4)) + return result[3] + } + + Expect(keyFileOption("store-a-key")).ToNot(Equal(keyFileOption("store-b-key"))) + }) +}) diff --git a/pkg/credentials/env.go b/pkg/credentials/env.go index d7810288..3433aa12 100644 --- a/pkg/credentials/env.go +++ b/pkg/credentials/env.go @@ -22,6 +22,7 @@ package credentials import ( "context" "fmt" + "sync" machineryapi "github.com/cloudnative-pg/machinery/pkg/api" "github.com/cloudnative-pg/machinery/pkg/fileutils" @@ -29,6 +30,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" barmanApi "github.com/cloudnative-pg/barman-cloud/pkg/api" + "github.com/cloudnative-pg/barman-cloud/pkg/utils" ) const ( @@ -142,6 +144,13 @@ func envSetAWSCredentials( return nil, fmt.Errorf("missing S3 credentials") } + // Materialize the SSE-C customer key, if any, before the auth-method + // handling below: SSE-C is orthogonal to authentication and must be + // available for every method, including inheritFromIAMRole. + if err := reconcileAWSSSECustomerKey(ctx, client, namespace, s3credentials); err != nil { + return nil, err + } + if s3credentials.InheritFromIAMRole { return env, nil } @@ -207,6 +216,45 @@ func envSetAWSCredentials( return env, nil } +// sseCustomerKeyMutex serializes the writes of the SSE-C customer key files. +// fileutils.WriteFileAtomic names its temporary file after the current second, +// so two concurrent writes of the same key file would share it, and one of +// them could truncate the file the other has just renamed into place. +var sseCustomerKeyMutex sync.Mutex + +// reconcileAWSSSECustomerKey materializes the S3 SSE-C customer key file +// referenced by the S3 credentials. barman-cloud consumes it through the +// '--sse-customer-key file://' option, so the key must exist on disk next to +// the process that runs the barman-cloud commands. The file path depends on +// the referenced secret and key: a single process can serve object stores +// with different keys concurrently (e.g. a replica cluster archiving to its +// own object store while restoring from the source one), and a shared file +// would let a command pick up the key of another object store. The +// referenced secret is expected to contain a base64-encoded 256-bit AES key, +// which barman-cloud validates when it reads the file. +func reconcileAWSSSECustomerKey( + ctx context.Context, + c client.Client, + namespace string, + s3credentials *barmanApi.S3Credentials, +) error { + if s3credentials.SSECustomerKey == nil { + return nil + } + + key, err := extractValueFromSecret(ctx, c, s3credentials.SSECustomerKey, namespace) + if err != nil { + return err + } + + sseCustomerKeyMutex.Lock() + defer sseCustomerKeyMutex.Unlock() + _, err = fileutils.WriteFileAtomic( + utils.SSECustomerKeyFilePath(s3credentials.SSECustomerKey), key, 0o600) + + return err +} + // envSetAzureCredentials sets the Azure environment variables given the configuration // inside the cluster func envSetAzureCredentials( diff --git a/pkg/utils/barman.go b/pkg/utils/barman.go index cbeafc61..8989b938 100644 --- a/pkg/utils/barman.go +++ b/pkg/utils/barman.go @@ -23,7 +23,10 @@ import ( "errors" "fmt" "math" + "path" "regexp" + + machineryapi "github.com/cloudnative-pg/machinery/pkg/api" ) var regexPolicy = regexp.MustCompile(`([1-9][0-9]*)([dwm])$`) @@ -63,3 +66,11 @@ func MapToBarmanTagsFormat(option string, mapTags map[string]string) ([]string, return tags, nil } + +// SSECustomerKeyFilePath returns the path where the S3 SSE-C customer key +// referenced by the passed selector is materialized. The path depends on the +// referenced secret and key, so that object stores using different keys +// never share the same file, even when their commands run concurrently. +func SSECustomerKeyFilePath(selector *machineryapi.SecretKeySelector) string { + return path.Join(SSECustomerKeysDirectory, selector.Name, selector.Key) +} diff --git a/pkg/utils/barman_test.go b/pkg/utils/barman_test.go index dcc75287..e6aab5f8 100644 --- a/pkg/utils/barman_test.go +++ b/pkg/utils/barman_test.go @@ -20,6 +20,8 @@ SPDX-License-Identifier: Apache-2.0 package utils //nolint:revive import ( + machineryapi "github.com/cloudnative-pg/machinery/pkg/api" + . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -54,3 +56,26 @@ var _ = Describe("converting map to barman tags format", func() { Expect(MapToBarmanTagsFormat("test", tags)).To(BeEquivalentTo([]string{"test", "retentionDays,90days"})) }) }) + +var _ = Describe("SSE-C customer key file path", func() { + selector := func(name, key string) *machineryapi.SecretKeySelector { + return &machineryapi.SecretKeySelector{ + LocalObjectReference: machineryapi.LocalObjectReference{Name: name}, + Key: key, + } + } + + It("never shares a file between different secret keys", func() { + paths := []string{ + SSECustomerKeyFilePath(selector("store-a", "key")), + SSECustomerKeyFilePath(selector("store-b", "key")), + SSECustomerKeyFilePath(selector("store-a", "other")), + SSECustomerKeyFilePath(selector("store-a.key", "x")), + SSECustomerKeyFilePath(selector("store-a", "key.x")), + } + Expect(paths).To(HaveLen(5)) + for i := range paths { + Expect(paths[i+1:]).ToNot(ContainElement(paths[i])) + } + }) +}) diff --git a/pkg/utils/constants.go b/pkg/utils/constants.go index ed7dc775..76af7302 100644 --- a/pkg/utils/constants.go +++ b/pkg/utils/constants.go @@ -43,4 +43,9 @@ const ( // BarmanCloudCheckWalArchive is the command name for 'barman-cloud-check-wal-archive' BarmanCloudCheckWalArchive = "barman-cloud-check-wal-archive" + + // SSECustomerKeysDirectory is the directory where the S3 SSE-C customer + // keys are materialized from their secrets so that they can be passed to + // the barman-cloud commands via the '--sse-customer-key file://' option. + SSECustomerKeysDirectory = "/controller/.sse-customer-keys" )