diff --git a/.wordlist.txt b/.wordlist.txt index ca97df685..0ca777bcb 100644 --- a/.wordlist.txt +++ b/.wordlist.txt @@ -1,3 +1,4 @@ +AES AKS AccessDenied AdditionalContainerArgs @@ -21,11 +22,13 @@ EnvVar GCP GKE Gi +Hetzner IAM IRSA IfNotPresent InstanceSidecarConfiguration JSON +KMS Kustomize Lifecycle Linode @@ -95,6 +98,7 @@ creds csi customresourcedefinition declaratively +decrypt deps desc devel @@ -131,6 +135,7 @@ namespaces nonResourceURLs objectstore objectstores +openssl pluginConfiguration podName postgres @@ -155,6 +160,7 @@ serverName serviceaccount sha sig +sse storageClass subcommand tfddg diff --git a/api/v1/objectstore_types.go b/api/v1/objectstore_types.go index 928d52c98..e9964bbb6 100644 --- a/api/v1/objectstore_types.go +++ b/api/v1/objectstore_types.go @@ -59,6 +59,8 @@ type InstanceSidecarConfiguration struct { type ObjectStoreSpec struct { // The configuration for the barman-cloud tool suite // +kubebuilder:validation:XValidation:rule="!has(self.serverName)",fieldPath=".serverName",reason="FieldValueForbidden",message="use the 'serverName' plugin parameter in the Cluster resource" + // +kubebuilder:validation:XValidation:rule="!has(self.s3Credentials) || !has(self.s3Credentials.sseCustomerKey) || !has(self.data) || !has(self.data.encryption)",fieldPath=".data.encryption",reason="FieldValueForbidden",message="cannot be used together with s3Credentials.sseCustomerKey" + // +kubebuilder:validation:XValidation:rule="!has(self.s3Credentials) || !has(self.s3Credentials.sseCustomerKey) || !has(self.wal) || !has(self.wal.encryption)",fieldPath=".wal.encryption",reason="FieldValueForbidden",message="cannot be used together with s3Credentials.sseCustomerKey" Configuration barmanapi.BarmanObjectStoreConfiguration `json:"configuration"` // RetentionPolicy is the retention policy to be used for backups diff --git a/config/crd/bases/barmancloud.cnpg.io_objectstores.yaml b/config/crd/bases/barmancloud.cnpg.io_objectstores.yaml index fe68d8714..125824f9a 100644 --- a/config/crd/bases/barmancloud.cnpg.io_objectstores.yaml +++ b/config/crd/bases/barmancloud.cnpg.io_objectstores.yaml @@ -443,6 +443,16 @@ spec: message: use the 'serverName' plugin parameter in the Cluster resource reason: FieldValueForbidden rule: '!has(self.serverName)' + - fieldPath: .data.encryption + message: cannot be used together with s3Credentials.sseCustomerKey + reason: FieldValueForbidden + rule: '!has(self.s3Credentials) || !has(self.s3Credentials.sseCustomerKey) + || !has(self.data) || !has(self.data.encryption)' + - fieldPath: .wal.encryption + message: cannot be used together with s3Credentials.sseCustomerKey + reason: FieldValueForbidden + rule: '!has(self.s3Credentials) || !has(self.s3Credentials.sseCustomerKey) + || !has(self.wal) || !has(self.wal.encryption)' - fieldPath: .s3AddressingStyle message: s3AddressingStyle requires s3Credentials reason: FieldValueForbidden diff --git a/internal/cnpgi/operator/specs/role.go b/internal/cnpgi/operator/specs/role.go index a53278d61..8d484a8c2 100644 --- a/internal/cnpgi/operator/specs/role.go +++ b/internal/cnpgi/operator/specs/role.go @@ -55,7 +55,7 @@ func BuildRoleRules(barmanObjects []barmancloudv1.ObjectStore) []rbacv1.PolicyRu for _, barmanObject := range barmanObjects { barmanObjectsSet.Put(barmanObject.Name) - for _, secret := range CollectSecretNamesFromCredentials(&barmanObject.Spec.Configuration.BarmanCredentials) { + for _, secret := range barmanObject.Spec.Configuration.SecretNames() { secretsSet.Put(secret) } } diff --git a/internal/cnpgi/operator/specs/role_test.go b/internal/cnpgi/operator/specs/role_test.go index bb5c9a13b..dd7ca3684 100644 --- a/internal/cnpgi/operator/specs/role_test.go +++ b/internal/cnpgi/operator/specs/role_test.go @@ -87,6 +87,16 @@ var _ = Describe("BuildRoleRules", func() { Expect(rules[2].ResourceNames).To(BeEmpty()) }) + It("should grant access to the SSE-C customer key secret", func() { + store := newTestObjectStore("store-a", "secret-a") + store.Spec.Configuration.AWS.SSECustomerKey = &machineryapi.SecretKeySelector{ + LocalObjectReference: machineryapi.LocalObjectReference{Name: "sse-c-key"}, + Key: "key", + } + rules := BuildRoleRules([]barmancloudv1.ObjectStore{store}) + Expect(rules[2].ResourceNames).To(ConsistOf("secret-a", "sse-c-key")) + }) + It("should deduplicate secret names across ObjectStores", func() { objects := []barmancloudv1.ObjectStore{ newTestObjectStore("store-a", "shared-secret"), diff --git a/internal/cnpgi/operator/specs/secrets.go b/internal/cnpgi/operator/specs/secrets.go deleted file mode 100644 index 89811ad2b..000000000 --- a/internal/cnpgi/operator/specs/secrets.go +++ /dev/null @@ -1,70 +0,0 @@ -/* -Copyright © contributors to CloudNativePG, established as -CloudNativePG a Series of LF Projects, LLC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -SPDX-License-Identifier: Apache-2.0 -*/ - -package specs - -import ( - barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api" - machineryapi "github.com/cloudnative-pg/machinery/pkg/api" -) - -// CollectSecretNamesFromCredentials collects the names of the secrets -func CollectSecretNamesFromCredentials(barmanCredentials *barmanapi.BarmanCredentials) []string { - var references []*machineryapi.SecretKeySelector - if barmanCredentials.AWS != nil { - references = append( - references, - barmanCredentials.AWS.AccessKeyIDReference, - barmanCredentials.AWS.SecretAccessKeyReference, - barmanCredentials.AWS.RegionReference, - barmanCredentials.AWS.SessionToken, - ) - } - if barmanCredentials.Azure != nil { - // When using default Azure credentials or managed identity, no secrets are required - if !barmanCredentials.Azure.UseDefaultAzureCredentials && - !barmanCredentials.Azure.InheritFromAzureAD { - references = append( - references, - barmanCredentials.Azure.ConnectionString, - barmanCredentials.Azure.StorageAccount, - barmanCredentials.Azure.StorageKey, - barmanCredentials.Azure.StorageSasToken, - ) - } - } - if barmanCredentials.Google != nil { - references = append( - references, - barmanCredentials.Google.ApplicationCredentials, - ) - } - - result := make([]string, 0, len(references)) - for _, reference := range references { - if reference == nil { - continue - } - result = append(result, reference.Name) - } - - // TODO: stringset belongs to machinery :( - - return result -} diff --git a/internal/cnpgi/operator/specs/secrets_test.go b/internal/cnpgi/operator/specs/secrets_test.go deleted file mode 100644 index d6fa706bf..000000000 --- a/internal/cnpgi/operator/specs/secrets_test.go +++ /dev/null @@ -1,227 +0,0 @@ -/* -Copyright © contributors to CloudNativePG, established as -CloudNativePG a Series of LF Projects, LLC. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. - -SPDX-License-Identifier: Apache-2.0 -*/ - -package specs - -import ( - barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api" - machineryapi "github.com/cloudnative-pg/machinery/pkg/api" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" -) - -var _ = Describe("CollectSecretNamesFromCredentials", func() { - Context("when collecting secrets from AWS credentials", func() { - It("should return secret names from S3 credentials", func() { - credentials := &barmanapi.BarmanCredentials{ - AWS: &barmanapi.S3Credentials{ - AccessKeyIDReference: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "aws-secret", - }, - Key: "access-key-id", - }, - SecretAccessKeyReference: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "aws-secret", - }, - Key: "secret-access-key", - }, - }, - } - - secrets := CollectSecretNamesFromCredentials(credentials) - Expect(secrets).To(ContainElement("aws-secret")) - }) - - It("should handle nil AWS credentials", func() { - credentials := &barmanapi.BarmanCredentials{} - - secrets := CollectSecretNamesFromCredentials(credentials) - Expect(secrets).To(BeEmpty()) - }) - }) - - Context("when collecting secrets from Azure credentials", func() { - It("should return secret names when using explicit credentials", func() { - credentials := &barmanapi.BarmanCredentials{ - Azure: &barmanapi.AzureCredentials{ - ConnectionString: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "azure-secret", - }, - Key: "connection-string", - }, - }, - } - - secrets := CollectSecretNamesFromCredentials(credentials) - Expect(secrets).To(ContainElement("azure-secret")) - }) - - It("should return empty list when using UseDefaultAzureCredentials", func() { - credentials := &barmanapi.BarmanCredentials{ - Azure: &barmanapi.AzureCredentials{ - UseDefaultAzureCredentials: true, - ConnectionString: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "azure-secret", - }, - Key: "connection-string", - }, - }, - } - - secrets := CollectSecretNamesFromCredentials(credentials) - Expect(secrets).To(BeEmpty()) - }) - - It("should return empty list when using InheritFromAzureAD", func() { - credentials := &barmanapi.BarmanCredentials{ - Azure: &barmanapi.AzureCredentials{ - InheritFromAzureAD: true, - }, - } - - secrets := CollectSecretNamesFromCredentials(credentials) - Expect(secrets).To(BeEmpty()) - }) - - It("should return secret names for storage account and key", func() { - credentials := &barmanapi.BarmanCredentials{ - Azure: &barmanapi.AzureCredentials{ - StorageAccount: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "azure-storage", - }, - Key: "account-name", - }, - StorageKey: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "azure-storage", - }, - Key: "account-key", - }, - }, - } - - secrets := CollectSecretNamesFromCredentials(credentials) - Expect(secrets).To(ContainElement("azure-storage")) - }) - }) - - Context("when collecting secrets from Google credentials", func() { - It("should return secret names from Google credentials", func() { - credentials := &barmanapi.BarmanCredentials{ - Google: &barmanapi.GoogleCredentials{ - ApplicationCredentials: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "google-secret", - }, - Key: "credentials.json", - }, - }, - } - - secrets := CollectSecretNamesFromCredentials(credentials) - Expect(secrets).To(ContainElement("google-secret")) - }) - }) - - Context("when collecting secrets from multiple cloud providers", func() { - It("should return secret names from all providers", func() { - credentials := &barmanapi.BarmanCredentials{ - AWS: &barmanapi.S3Credentials{ - AccessKeyIDReference: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "aws-secret", - }, - Key: "access-key-id", - }, - }, - Azure: &barmanapi.AzureCredentials{ - ConnectionString: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "azure-secret", - }, - Key: "connection-string", - }, - }, - Google: &barmanapi.GoogleCredentials{ - ApplicationCredentials: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "google-secret", - }, - Key: "credentials.json", - }, - }, - } - - secrets := CollectSecretNamesFromCredentials(credentials) - Expect(secrets).To(ContainElements("aws-secret", "azure-secret", "google-secret")) - }) - - It("should skip Azure secrets when using UseDefaultAzureCredentials with other providers", func() { - credentials := &barmanapi.BarmanCredentials{ - AWS: &barmanapi.S3Credentials{ - AccessKeyIDReference: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "aws-secret", - }, - Key: "access-key-id", - }, - }, - Azure: &barmanapi.AzureCredentials{ - UseDefaultAzureCredentials: true, - ConnectionString: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "azure-secret", - }, - Key: "connection-string", - }, - }, - } - - secrets := CollectSecretNamesFromCredentials(credentials) - Expect(secrets).To(ContainElement("aws-secret")) - Expect(secrets).NotTo(ContainElement("azure-secret")) - }) - }) - - Context("when handling nil references", func() { - It("should skip nil secret references", func() { - credentials := &barmanapi.BarmanCredentials{ - AWS: &barmanapi.S3Credentials{ - AccessKeyIDReference: &machineryapi.SecretKeySelector{ - LocalObjectReference: machineryapi.LocalObjectReference{ - Name: "aws-secret", - }, - Key: "access-key-id", - }, - SecretAccessKeyReference: nil, - }, - } - - secrets := CollectSecretNamesFromCredentials(credentials) - Expect(secrets).To(ContainElement("aws-secret")) - Expect(len(secrets)).To(Equal(1)) - }) - }) -}) diff --git a/manifest.yaml b/manifest.yaml index bc89a459f..b21a7c3cc 100644 --- a/manifest.yaml +++ b/manifest.yaml @@ -442,6 +442,16 @@ spec: message: use the 'serverName' plugin parameter in the Cluster resource reason: FieldValueForbidden rule: '!has(self.serverName)' + - fieldPath: .data.encryption + message: cannot be used together with s3Credentials.sseCustomerKey + reason: FieldValueForbidden + rule: '!has(self.s3Credentials) || !has(self.s3Credentials.sseCustomerKey) + || !has(self.data) || !has(self.data.encryption)' + - fieldPath: .wal.encryption + message: cannot be used together with s3Credentials.sseCustomerKey + reason: FieldValueForbidden + rule: '!has(self.s3Credentials) || !has(self.s3Credentials.sseCustomerKey) + || !has(self.wal) || !has(self.wal.encryption)' - fieldPath: .s3AddressingStyle message: s3AddressingStyle requires s3Credentials reason: FieldValueForbidden diff --git a/test/e2e/internal/objectstore/objectstore.go b/test/e2e/internal/objectstore/objectstore.go index ad3efe7d3..92de387cb 100644 --- a/test/e2e/internal/objectstore/objectstore.go +++ b/test/e2e/internal/objectstore/objectstore.go @@ -39,6 +39,8 @@ type Resources struct { Service *corev1.Service Secret *corev1.Secret PVC *corev1.PersistentVolumeClaim + // SSECustomerKey is the secret holding the SSE-C key of the object store, if any + SSECustomerKey *corev1.Secret } // Create creates the object store resources. @@ -53,6 +55,11 @@ func (osr Resources) Create(ctx context.Context, cl client.Client) error { return fmt.Errorf("failed to create secret: %w", err) } } + if osr.SSECustomerKey != nil { + if err := cl.Create(ctx, osr.SSECustomerKey); err != nil { + return fmt.Errorf("failed to create SSE-C key secret: %w", err) + } + } if osr.Deployment != nil { if err := cl.Create(ctx, osr.Deployment); err != nil { return fmt.Errorf("failed to create deployment: %w", err) diff --git a/test/e2e/internal/objectstore/s3.go b/test/e2e/internal/objectstore/s3.go index 17acc6f34..cf89d4cde 100644 --- a/test/e2e/internal/objectstore/s3.go +++ b/test/e2e/internal/objectstore/s3.go @@ -20,6 +20,8 @@ SPDX-License-Identifier: Apache-2.0 package objectstore import ( + "crypto/rand" + "encoding/base64" "fmt" "net" @@ -243,8 +245,8 @@ func newS3Secret(namespace, name string) *corev1.Secret { Namespace: namespace, }, Data: map[string][]byte{ - "ACCESS_KEY_ID": []byte("s3accesskey"), - "ACCESS_SECRET_KEY": []byte("s3secretkey123"), + "ACCESS_KEY_ID": []byte(S3AccessKeyID), + "ACCESS_SECRET_KEY": []byte(S3SecretAccessKey), }, } } @@ -272,6 +274,39 @@ func newS3PVC(namespace, name string) *corev1.PersistentVolumeClaim { } } +// S3AccessKeyID and S3SecretAccessKey are the credentials of the S3-compatible +// object store created by NewS3ObjectStoreResources. +const ( + S3AccessKeyID = "s3accesskey" + S3SecretAccessKey = "s3secretkey123" +) + +// SSECustomerKeySecretKey is the key of the SSE-C key in the secret created by +// NewSSECustomerKeySecret. +const SSECustomerKeySecretKey = "key" + +// NewSSECustomerKeySecret creates a secret holding a random base64-encoded +// 256-bit SSE-C key, and returns it together with the encoded key. +func NewSSECustomerKeySecret(namespace, name string) (*corev1.Secret, string) { + raw := make([]byte, 32) + _, _ = rand.Read(raw) + key := base64.StdEncoding.EncodeToString(raw) + + return &corev1.Secret{ + TypeMeta: metav1.TypeMeta{ + Kind: "Secret", + APIVersion: "v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: map[string][]byte{ + SSECustomerKeySecretKey: []byte(key), + }, + }, key +} + // NewS3ObjectStore creates a new ObjectStore pointing at the S3-compatible // object store created by NewS3ObjectStoreResources with the given name. func NewS3ObjectStore(namespace, name, s3Name string) *pluginBarmanCloudV1.ObjectStore { diff --git a/test/e2e/internal/tests/replicacluster/fixtures.go b/test/e2e/internal/tests/replicacluster/fixtures.go index 148466999..0a54b1ec3 100644 --- a/test/e2e/internal/tests/replicacluster/fixtures.go +++ b/test/e2e/internal/tests/replicacluster/fixtures.go @@ -21,6 +21,7 @@ package replicacluster import ( cloudnativepgv1 "github.com/cloudnative-pg/api/pkg/api/v1" + machineryapi "github.com/cloudnative-pg/machinery/pkg/api" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/utils/ptr" @@ -58,6 +59,9 @@ type replicaClusterTestResources struct { ReplicaObjectStore *pluginBarmanCloudV1.ObjectStore ReplicaCluster *cloudnativepgv1.Cluster ReplicaBackup *cloudnativepgv1.Backup + // SSECustomerKeys maps the S3 endpoint of each object store to the + // base64-encoded SSE-C key its objects must be encrypted with + SSECustomerKeys map[string]string } type s3ReplicaClusterFactory struct{} @@ -77,6 +81,36 @@ func (f s3ReplicaClusterFactory) createReplicaClusterTestResources(namespace str return result } +// s3SSECReplicaClusterFactory encrypts each object store with its own SSE-C +// key: the designated primary of the replica cluster archives WAL to one +// object store while restoring it from the other, in the same sidecar. +type s3SSECReplicaClusterFactory struct{} + +func (f s3SSECReplicaClusterFactory) createReplicaClusterTestResources( + namespace string, +) replicaClusterTestResources { + result := s3ReplicaClusterFactory{}.createReplicaClusterTestResources(namespace) + result.SSECustomerKeys = map[string]string{} + + for _, store := range []struct { + resources *objectstore.Resources + objectStore *pluginBarmanCloudV1.ObjectStore + }{ + {result.SrcObjectStoreResources, result.SrcObjectStore}, + {result.ReplicaObjectStoreResources, result.ReplicaObjectStore}, + } { + secret, key := objectstore.NewSSECustomerKeySecret(namespace, store.objectStore.Name+"-sse-c") + store.resources.SSECustomerKey = secret + store.objectStore.Spec.Configuration.AWS.SSECustomerKey = &machineryapi.SecretKeySelector{ + LocalObjectReference: machineryapi.LocalObjectReference{Name: secret.Name}, + Key: objectstore.SSECustomerKeySecretKey, + } + result.SSECustomerKeys[store.objectStore.Spec.Configuration.EndpointURL] = key + } + + return result +} + type gcsReplicaClusterFactory struct{} func (f gcsReplicaClusterFactory) createReplicaClusterTestResources(namespace string) replicaClusterTestResources { diff --git a/test/e2e/internal/tests/replicacluster/replica_cluster.go b/test/e2e/internal/tests/replicacluster/replica_cluster.go index ee7934a23..9c04b533e 100644 --- a/test/e2e/internal/tests/replicacluster/replica_cluster.go +++ b/test/e2e/internal/tests/replicacluster/replica_cluster.go @@ -25,7 +25,9 @@ import ( "time" cloudnativepgv1 "github.com/cloudnative-pg/api/pkg/api/v1" + barmanapi "github.com/cloudnative-pg/barman-cloud/pkg/api" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" @@ -34,11 +36,36 @@ import ( cluster2 "github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/cluster" "github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/command" nmsp "github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/namespace" + "github.com/cloudnative-pg/plugin-barman-cloud/test/e2e/internal/objectstore" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +// checkSSECustomerKeyScript prints how many objects the bucket holds and how +// many of them are not readable with the given SSE-C key alone: readable +// without a key, with the other key, or not with the given one +const checkSSECustomerKeyScript = ` +import base64, sys, boto3, botocore +endpoint, access_key, secret_key, key, other_key = sys.argv[1:6] +s3 = boto3.client("s3", endpoint_url=endpoint, region_name="us-east-1", + aws_access_key_id=access_key, aws_secret_access_key=secret_key) +def readable(name, sse_key=None): + args = {} + if sse_key: + args = {"SSECustomerAlgorithm": "AES256", "SSECustomerKey": base64.b64decode(sse_key)} + try: + s3.head_object(Bucket="backups", Key=name, **args) + return True + except botocore.exceptions.ClientError: + return False +names = [o["Key"] for page in s3.get_paginator("list_objects_v2").paginate(Bucket="backups") + for o in page.get("Contents", [])] +bad = [n for n in names if readable(n) or readable(n, other_key) or not readable(n, key)] +print("objects=%d misencrypted=%d" % (len(names), len(bad))) +print(bad[:10]) +` + var _ = Describe("Replica cluster", func() { var namespace *corev1.Namespace var cl client.Client @@ -75,6 +102,18 @@ var _ = Describe("Replica cluster", func() { Expect(cl.Create(ctx, testResources.ReplicaObjectStore)).To(Succeed()) } + if testResources.SSECustomerKeys != nil { + By("rejecting an ObjectStore that combines SSE-C with bucket-managed encryption") + invalid := testResources.SrcObjectStore.DeepCopy() + invalid.ObjectMeta = metav1.ObjectMeta{Name: "invalid-sse-c", Namespace: namespace.Name} + invalid.Spec.Configuration.Wal = &barmanapi.WalBackupConfiguration{ + Encryption: barmanapi.EncryptionTypeAES256, + } + err := cl.Create(ctx, invalid) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot be used together with s3Credentials.sseCustomerKey")) + } + By("Creating a CloudNativePG cluster") src := testResources.SrcCluster Expect(cl.Create(ctx, testResources.SrcCluster)).To(Succeed()) @@ -264,11 +303,41 @@ var _ = Describe("Replica cluster", func() { g.Expect(err).NotTo(HaveOccurred()) g.Expect(output).To(BeEquivalentTo("2\n")) }).Within(2 * time.Minute).WithPolling(5 * time.Second).Should(Succeed()) + + for endpoint, key := range testResources.SSECustomerKeys { + By(fmt.Sprintf("checking every object in %s is encrypted with its own SSE-C key", endpoint)) + var otherKey string + for otherEndpoint, k := range testResources.SSECustomerKeys { + if otherEndpoint != endpoint { + otherKey = k + } + } + output, stdErr, err := command.ExecuteInContainer(ctx, + *clientSet, + cfg, + command.ContainerLocator{ + NamespaceName: replica.Namespace, + PodName: fmt.Sprintf("%v-1", replica.Name), + ContainerName: "plugin-barman-cloud", + }, + nil, + []string{ + "/venv/bin/python3", "-c", checkSSECustomerKeyScript, + endpoint, objectstore.S3AccessKeyID, objectstore.S3SecretAccessKey, key, otherKey, + }) + Expect(err).NotTo(HaveOccurred(), "stderr: %s", stdErr) + GinkgoWriter.Printf("%s: %s", endpoint, output) + Expect(output).To(MatchRegexp(`^objects=[1-9][0-9]* misencrypted=0\n`)) + } }, Entry( "with S3", s3ReplicaClusterFactory{}, ), + Entry( + "with S3 and a different SSE-C key for each object store", + s3SSECReplicaClusterFactory{}, + ), Entry( "with Azurite", azuriteReplicaClusterFactory{}, diff --git a/web/docs/object_stores.md b/web/docs/object_stores.md index 39f7d7884..02874f40d 100644 --- a/web/docs/object_stores.md +++ b/web/docs/object_stores.md @@ -203,6 +203,68 @@ spec: [...] ``` +### Server-Side Encryption with Customer Keys (SSE-C) + +Some S3-compatible providers, most notably **Hetzner Object Storage**, do +not offer bucket-managed server-side encryption (SSE-S3 / SSE-KMS) and instead +only support **Server-Side Encryption with Customer-provided keys (SSE-C)**. +With SSE-C the encryption key never leaves your control: it is supplied with +every request, and the provider uses it to encrypt and decrypt objects without +storing it. + +To enable SSE-C, set the `sseCustomerKey` field in the `s3Credentials` block to +a secret reference holding a **base64-encoded 256-bit (32-byte) AES key**. + +Generate the key and store it in a Kubernetes secret: + +```sh +# Generate a random 256-bit key, base64-encoded +openssl rand 32 | base64 > encryption.key + +kubectl create secret generic aws-sse-c \ + --from-file=key=encryption.key +``` + +:::warning +Keep this key safe and backed up **outside** the object store. If you lose +it, your backups and WAL files become permanently unrecoverable. The +provider cannot decrypt them for you. +::: + +Reference it in your `ObjectStore` definition: + +```yaml +apiVersion: barmancloud.cnpg.io/v1 +kind: ObjectStore +metadata: + name: hetzner-store +spec: + configuration: + destinationPath: "s3://BUCKET_NAME/path/to/folder" + endpointURL: "https://fsn1.your-objectstorage.com" + s3Credentials: + accessKeyId: + name: aws-creds + key: ACCESS_KEY_ID + secretAccessKey: + name: aws-creds + key: ACCESS_SECRET_KEY + sseCustomerKey: + name: aws-sse-c + key: key + [...] +``` + +The same key is applied to **every** operation (base backups, WAL archiving, +WAL restore, and data restore). It must remain unchanged and available for +the whole lifetime of the backups it protects. To use a new key, use a new +`destinationPath` or `serverName`. + +`sseCustomerKey` can be combined with any authentication method, including +`inheritFromIAMRole`. It cannot be combined with the bucket-managed +`encryption` setting (SSE-S3 / SSE-KMS) of the `data` and `wal` sections. An +`ObjectStore` that sets both is rejected when it is created or updated. + ### Using Object Storage with a Private CA For object storage services (e.g., RustFS) that use HTTPS with certificates