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
4 changes: 3 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -282,7 +282,9 @@ jobs:
set -xe
echo "CLICKHOUSE_VERSION=${CLICKHOUSE_VERSION}"
echo "GCS_TESTS=${GCS_TESTS}"

GCS_ENCRYPTION_KEY=$(openssl rand -base64 32)
export GCS_ENCRYPTION_KEY

chmod +x $(pwd)/clickhouse-backup/clickhouse-backup*

if [[ "${CLICKHOUSE_VERSION}" =~ 2[2-9]+ ]]; then
Expand Down
4 changes: 4 additions & 0 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ gcs:
custom_storage_class_map: {}
debug: false # GCS_DEBUG
force_http: false # GCS_FORCE_HTTP
# GCS_ENCRYPTION_KEY, base64-encoded 256-bit key for customer-supplied encryption (CSEK)
# This encrypts backup data at rest using a key you control. Generate with: `openssl rand -base64 32`
# See https://cloud.google.com/storage/docs/encryption/customer-supplied-keys
encryption_key: ""
cos:
url: "" # COS_URL
timeout: 2m # COS_TIMEOUT
Expand Down
3 changes: 3 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,9 @@ type GCSConfig struct {
// UploadConcurrency or DownloadConcurrency in each upload and download case
ClientPoolSize int `yaml:"client_pool_size" envconfig:"GCS_CLIENT_POOL_SIZE"`
ChunkSize int `yaml:"chunk_size" envconfig:"GCS_CHUNK_SIZE"`
// EncryptionKey is a base64-encoded 256-bit customer-supplied encryption key (CSEK)
// for client-side encryption of objects. Use `openssl rand -base64 32` to generate.
EncryptionKey string `yaml:"encryption_key" envconfig:"GCS_ENCRYPTION_KEY"`
}

// AzureBlobConfig - Azure Blob settings section
Expand Down
48 changes: 39 additions & 9 deletions pkg/storage/gcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,10 @@ import (

// GCS - presents methods for manipulate data on GCS
type GCS struct {
client *storage.Client
Config *config.GCSConfig
clientPool *pool.ObjectPool
client *storage.Client
Config *config.GCSConfig
clientPool *pool.ObjectPool
encryptionKey []byte // Customer-Supplied Encryption Key (CSEK)
}

type debugGCSTransport struct {
Expand Down Expand Up @@ -188,14 +189,39 @@ func (gcs *GCS) Connect(ctx context.Context) error {
gcs.clientPool = pool.NewObjectPoolWithDefaultConfig(ctx, factory)
gcs.clientPool.Config.MaxTotal = gcs.Config.ClientPoolSize * 3
gcs.client, err = storage.NewClient(ctx, storageClientOptions...)
return err
if err != nil {
return err
}

// Validate and decode the encryption key if provided
if gcs.Config.EncryptionKey != "" {
key, err := base64.StdEncoding.DecodeString(gcs.Config.EncryptionKey)
if err != nil {
return errors.Wrap(err, "gcs: malformed encryption_key, must be base64-encoded 256-bit key")
}
if len(key) != 32 {
return fmt.Errorf("gcs: malformed encryption_key, must be base64-encoded 256-bit key (got %d bytes)", len(key))
}
gcs.encryptionKey = key
log.Info().Msg("GCS: Customer-Supplied Encryption Key (CSEK) configured")
}

return nil
}

func (gcs *GCS) Close(ctx context.Context) error {
gcs.clientPool.Close(ctx)
return gcs.client.Close()
}

// applyEncryption returns an ObjectHandle with encryption key applied if configured
func (gcs *GCS) applyEncryption(obj *storage.ObjectHandle) *storage.ObjectHandle {
if gcs.encryptionKey != nil {
return obj.Key(gcs.encryptionKey)
}
return obj
}

func (gcs *GCS) Walk(ctx context.Context, gcsPath string, recursive bool, process func(ctx context.Context, r RemoteFile) error) error {
rootPath := path.Join(gcs.Config.Path, gcsPath)
return gcs.WalkAbsolute(ctx, rootPath, recursive, process)
Expand Down Expand Up @@ -252,7 +278,7 @@ func (gcs *GCS) GetFileReaderAbsolute(ctx context.Context, key string) (io.ReadC
return nil, err
}
pClient := pClientObj.(*clientObject).Client
obj := pClient.Bucket(gcs.Config.Bucket).Object(key)
obj := gcs.applyEncryption(pClient.Bucket(gcs.Config.Bucket).Object(key))
reader, err := obj.NewReader(ctx)
if err != nil {
if pErr := gcs.clientPool.InvalidateObject(ctx, pClientObj); pErr != nil {
Expand Down Expand Up @@ -281,7 +307,7 @@ func (gcs *GCS) PutFileAbsolute(ctx context.Context, key string, r io.ReadCloser
return err
}
pClient := pClientObj.(*clientObject).Client
obj := pClient.Bucket(gcs.Config.Bucket).Object(key)
obj := gcs.applyEncryption(pClient.Bucket(gcs.Config.Bucket).Object(key))
// always retry transient errors to mitigate retry logic bugs.
obj = obj.Retryer(storage.WithPolicy(storage.RetryAlways))
writer := obj.NewWriter(ctx)
Expand Down Expand Up @@ -314,7 +340,8 @@ func (gcs *GCS) StatFile(ctx context.Context, key string) (RemoteFile, error) {
}

func (gcs *GCS) StatFileAbsolute(ctx context.Context, key string) (RemoteFile, error) {
objAttr, err := gcs.client.Bucket(gcs.Config.Bucket).Object(key).Attrs(ctx)
obj := gcs.applyEncryption(gcs.client.Bucket(gcs.Config.Bucket).Object(key))
objAttr, err := obj.Attrs(ctx)
if err != nil {
if errors.Is(err, storage.ErrObjectNotExist) {
return nil, ErrNotFound
Expand Down Expand Up @@ -369,7 +396,7 @@ func (gcs *GCS) CopyObject(ctx context.Context, srcSize int64, srcBucket, srcKey
}
pClient := pClientObj.(*clientObject).Client
src := pClient.Bucket(srcBucket).Object(srcKey)
dst := pClient.Bucket(gcs.Config.Bucket).Object(dstKey)
dst := gcs.applyEncryption(pClient.Bucket(gcs.Config.Bucket).Object(dstKey))
// always retry transient errors to mitigate retry logic bugs.
dst = dst.Retryer(storage.WithPolicy(storage.RetryAlways))
attrs, err := src.Attrs(ctx)
Expand All @@ -379,7 +406,10 @@ func (gcs *GCS) CopyObject(ctx context.Context, srcSize int64, srcBucket, srcKey
}
return 0, err
}
if _, err = dst.CopierFrom(src).Run(ctx); err != nil {
copier := dst.CopierFrom(src)
// If encryption is enabled, the destination will be encrypted
// Note: source objects from object disks are not encrypted by clickhouse-backup
if _, err = copier.Run(ctx); err != nil {
if pErr := gcs.clientPool.InvalidateObject(ctx, pClientObj); pErr != nil {
log.Warn().Msgf("gcs.CopyObject: gcs.clientPool.InvalidateObject error: %+v", pErr)
}
Expand Down
184 changes: 184 additions & 0 deletions pkg/storage/gcs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
package storage

import (
"encoding/base64"
"fmt"
"testing"

"github.com/Altinity/clickhouse-backup/v2/pkg/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestGCSEncryptionKeyValidation(t *testing.T) {
testCases := []struct {
name string
encryptionKey string
expectError bool
errorContains string
}{
{
name: "empty key is valid (no encryption)",
encryptionKey: "",
expectError: false,
},
{
name: "valid 256-bit key",
encryptionKey: base64.StdEncoding.EncodeToString(make([]byte, 32)),
expectError: false,
},
{
name: "valid 256-bit key with random data",
encryptionKey: "dGhpcyBpcyBhIDMyIGJ5dGUga2V5ISEhISEhISEhISE=", // "this is a 32 byte key!!!!!!!!!!!" (32 bytes) base64
expectError: false,
},
{
name: "invalid base64",
encryptionKey: "not-valid-base64!!!",
expectError: true,
errorContains: "malformed encryption_key",
},
{
name: "key too short (16 bytes / 128-bit)",
encryptionKey: base64.StdEncoding.EncodeToString(make([]byte, 16)),
expectError: true,
errorContains: "got 16 bytes",
},
{
name: "key too long (64 bytes / 512-bit)",
encryptionKey: base64.StdEncoding.EncodeToString(make([]byte, 64)),
expectError: true,
errorContains: "got 64 bytes",
},
{
name: "key slightly short (31 bytes)",
encryptionKey: base64.StdEncoding.EncodeToString(make([]byte, 31)),
expectError: true,
errorContains: "got 31 bytes",
},
{
name: "key slightly long (33 bytes)",
encryptionKey: base64.StdEncoding.EncodeToString(make([]byte, 33)),
expectError: true,
errorContains: "got 33 bytes",
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
gcs := &GCS{
Config: &config.GCSConfig{
EncryptionKey: tc.encryptionKey,
// These are required for Connect but we'll test key validation
// before actual connection by checking the error
Bucket: "test-bucket",
SkipCredentials: true,
},
}

// We can't fully test Connect without a GCS server, but we can
// validate the key parsing logic by checking if the error is
// related to key validation vs connection issues
err := gcs.validateAndDecodeEncryptionKey()

if tc.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tc.errorContains)
assert.Nil(t, gcs.encryptionKey)
} else {
require.NoError(t, err)
if tc.encryptionKey != "" {
assert.NotNil(t, gcs.encryptionKey)
assert.Len(t, gcs.encryptionKey, 32)
} else {
assert.Nil(t, gcs.encryptionKey)
}
}
})
}
}

func TestGCSApplyEncryption(t *testing.T) {
t.Run("returns same object when no encryption key", func(t *testing.T) {
gcs := &GCS{
Config: &config.GCSConfig{},
encryptionKey: nil,
}

// We can't create a real ObjectHandle without a client, but we can
// verify the logic by checking the nil case
result := gcs.applyEncryption(nil)
assert.Nil(t, result)
})

t.Run("encryption key is set correctly", func(t *testing.T) {
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}

gcs := &GCS{
Config: &config.GCSConfig{},
encryptionKey: key,
}

// Verify the key is stored correctly
assert.Equal(t, key, gcs.encryptionKey)
assert.Len(t, gcs.encryptionKey, 32)
})
}

func TestGCSEncryptionKeyDecoding(t *testing.T) {
t.Run("correctly decodes valid base64 key", func(t *testing.T) {
// Create a known 32-byte key
originalKey := []byte("12345678901234567890123456789012") // exactly 32 bytes
encodedKey := base64.StdEncoding.EncodeToString(originalKey)

gcs := &GCS{
Config: &config.GCSConfig{
EncryptionKey: encodedKey,
},
}

err := gcs.validateAndDecodeEncryptionKey()
require.NoError(t, err)
assert.Equal(t, originalKey, gcs.encryptionKey)
})

t.Run("handles URL-safe base64 encoding", func(t *testing.T) {
// Standard base64 with potential + and / characters
originalKey := make([]byte, 32)
for i := range originalKey {
originalKey[i] = byte(i * 8) // Will produce various characters
}
encodedKey := base64.StdEncoding.EncodeToString(originalKey)

gcs := &GCS{
Config: &config.GCSConfig{
EncryptionKey: encodedKey,
},
}

err := gcs.validateAndDecodeEncryptionKey()
require.NoError(t, err)
assert.Equal(t, originalKey, gcs.encryptionKey)
})
}

// validateAndDecodeEncryptionKey is a helper that extracts the key validation
// logic for testing without needing a full GCS connection
func (gcs *GCS) validateAndDecodeEncryptionKey() error {
if gcs.Config.EncryptionKey == "" {
return nil
}

key, err := base64.StdEncoding.DecodeString(gcs.Config.EncryptionKey)
if err != nil {
return fmt.Errorf("gcs: malformed encryption_key, must be base64-encoded 256-bit key: %w", err)
}
if len(key) != 32 {
return fmt.Errorf("gcs: malformed encryption_key, must be base64-encoded 256-bit key (got %d bytes)", len(key))
}
gcs.encryptionKey = key
return nil
}
16 changes: 10 additions & 6 deletions test/integration/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@ services:
GCS_DEBUG: "${GCS_DEBUG:-false}"
FTP_DEBUG: "${FTP_DEBUG:-false}"
SFTP_DEBUG: "${SFTP_DEBUG:-false}"
AZBLOB_DEBUG: "${AZBLOB_DEBUG:-false}"
COS_DEBUG: "${COS_DEBUG:-false}"
AZBLOB_DEBUG: "${AZBLOB_DEBUG:-false}"
CLICKHOUSE_DEBUG: "${CLICKHOUSE_DEBUG:-false}"
GOCOVERDIR: "/tmp/_coverage_/"
# FIPS
Expand All @@ -131,15 +131,17 @@ services:
QA_GCS_OVER_S3_ACCESS_KEY: "${QA_GCS_OVER_S3_ACCESS_KEY}"
QA_GCS_OVER_S3_SECRET_KEY: "${QA_GCS_OVER_S3_SECRET_KEY}"
QA_GCS_OVER_S3_BUCKET: "${QA_GCS_OVER_S3_BUCKET}"
# AlibabaCloud over S3
# AlibabaCloud over S3
QA_ALIBABA_ACCESS_KEY: "${QA_ALIBABA_ACCESS_KEY:-}"
QA_ALIBABA_SECRET_KEY: "${QA_ALIBABA_SECRET_KEY:-}"
# Tencent Cloud Object Storage
# Tencent Cloud Object Storage
QA_TENCENT_SECRET_ID: "${QA_TENCENT_SECRET_ID:-}"
QA_TENCENT_SECRET_KEY: "${QA_TENCENT_SECRET_KEY:-}"
# https://github.com/Altinity/clickhouse-backup/issues/691:
AWS_ACCESS_KEY_ID: access_key
AWS_SECRET_ACCESS_KEY: it_is_my_super_secret_key
# GCS encryption key
GCS_ENCRYPTION_KEY: "${GCS_ENCRYPTION_KEY:-}"
volumes_from:
- clickhouse
ports:
Expand Down Expand Up @@ -181,13 +183,15 @@ services:
QA_GCS_OVER_S3_ACCESS_KEY: "${QA_GCS_OVER_S3_ACCESS_KEY}"
QA_GCS_OVER_S3_SECRET_KEY: "${QA_GCS_OVER_S3_SECRET_KEY}"
QA_GCS_OVER_S3_BUCKET: "${QA_GCS_OVER_S3_BUCKET}"
# AlibabaCloud over S3
# AlibabaCloud over S3
QA_ALIBABA_ACCESS_KEY: "${QA_ALIBABA_ACCESS_KEY:-}"
QA_ALIBABA_SECRET_KEY: "${QA_ALIBABA_SECRET_KEY:-}"
# Tencent Cloud Object Storage
# Tencent Cloud Object Storage
QA_TENCENT_SECRET_ID: "${QA_TENCENT_SECRET_ID:-}"
QA_TENCENT_SECRET_KEY: "${QA_TENCENT_SECRET_KEY:-}"

# GCS encryption key
GCS_ENCRYPTION_KEY: "${GCS_ENCRYPTION_KEY:-}"
# fix failures during try to IMDS initialization inside clickhouse
AWS_EC2_METADATA_DISABLED: "true"
volumes:
# clickhouse-backup related files requires for some tests
Expand Down
Loading
Loading