Skip to content

Commit 929adfc

Browse files
ns-janandaramclaude
andcommitted
feat: add GCS customer-supplied encryption key (CSEK) support
Add support for encrypting GCS backup data using customer-supplied encryption keys (CSEK). This provides client-side encryption where the encryption key is controlled by the user, not Google. Changes: - Add `encryption_key` config field to GCSConfig (GCS_ENCRYPTION_KEY env var) - Validate and decode base64 encryption key on connect (must be 256-bit) - Apply encryption to all object operations: read, write, stat, copy - Update documentation with usage instructions Usage: gcs: encryption_key: "" # base64-encoded 256-bit key # Generate with: openssl rand -base64 32 See: https://cloud.google.com/storage/docs/encryption/customer-supplied-keys Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ebec8c0 commit 929adfc

File tree

3 files changed

+46
-9
lines changed

3 files changed

+46
-9
lines changed

ReadMe.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,10 @@ gcs:
304304
custom_storage_class_map: {}
305305
debug: false # GCS_DEBUG
306306
force_http: false # GCS_FORCE_HTTP
307+
# GCS_ENCRYPTION_KEY, base64-encoded 256-bit key for customer-supplied encryption (CSEK)
308+
# This encrypts backup data at rest using a key you control. Generate with: `openssl rand -base64 32`
309+
# See https://cloud.google.com/storage/docs/encryption/customer-supplied-keys
310+
encryption_key: ""
307311
cos:
308312
url: "" # COS_URL
309313
timeout: 2m # COS_TIMEOUT

pkg/config/config.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,9 @@ type GCSConfig struct {
113113
// UploadConcurrency or DownloadConcurrency in each upload and download case
114114
ClientPoolSize int `yaml:"client_pool_size" envconfig:"GCS_CLIENT_POOL_SIZE"`
115115
ChunkSize int `yaml:"chunk_size" envconfig:"GCS_CHUNK_SIZE"`
116+
// EncryptionKey is a base64-encoded 256-bit customer-supplied encryption key (CSEK)
117+
// for client-side encryption of objects. Use `openssl rand -base64 32` to generate.
118+
EncryptionKey string `yaml:"encryption_key" envconfig:"GCS_ENCRYPTION_KEY"`
116119
}
117120

118121
// AzureBlobConfig - Azure Blob settings section

pkg/storage/gcs.go

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ import (
2626

2727
// GCS - presents methods for manipulate data on GCS
2828
type GCS struct {
29-
client *storage.Client
30-
Config *config.GCSConfig
31-
clientPool *pool.ObjectPool
29+
client *storage.Client
30+
Config *config.GCSConfig
31+
clientPool *pool.ObjectPool
32+
encryptionKey []byte // Customer-Supplied Encryption Key (CSEK)
3233
}
3334

3435
type debugGCSTransport struct {
@@ -188,14 +189,39 @@ func (gcs *GCS) Connect(ctx context.Context) error {
188189
gcs.clientPool = pool.NewObjectPoolWithDefaultConfig(ctx, factory)
189190
gcs.clientPool.Config.MaxTotal = gcs.Config.ClientPoolSize * 3
190191
gcs.client, err = storage.NewClient(ctx, storageClientOptions...)
191-
return err
192+
if err != nil {
193+
return err
194+
}
195+
196+
// Validate and decode the encryption key if provided
197+
if gcs.Config.EncryptionKey != "" {
198+
key, err := base64.StdEncoding.DecodeString(gcs.Config.EncryptionKey)
199+
if err != nil {
200+
return errors.Wrap(err, "gcs: malformed encryption_key, must be base64-encoded 256-bit key")
201+
}
202+
if len(key) != 32 {
203+
return fmt.Errorf("gcs: malformed encryption_key, must be base64-encoded 256-bit key (got %d bytes)", len(key))
204+
}
205+
gcs.encryptionKey = key
206+
log.Info().Msg("GCS: Customer-Supplied Encryption Key (CSEK) configured")
207+
}
208+
209+
return nil
192210
}
193211

194212
func (gcs *GCS) Close(ctx context.Context) error {
195213
gcs.clientPool.Close(ctx)
196214
return gcs.client.Close()
197215
}
198216

217+
// applyEncryption returns an ObjectHandle with encryption key applied if configured
218+
func (gcs *GCS) applyEncryption(obj *storage.ObjectHandle) *storage.ObjectHandle {
219+
if gcs.encryptionKey != nil {
220+
return obj.Key(gcs.encryptionKey)
221+
}
222+
return obj
223+
}
224+
199225
func (gcs *GCS) Walk(ctx context.Context, gcsPath string, recursive bool, process func(ctx context.Context, r RemoteFile) error) error {
200226
rootPath := path.Join(gcs.Config.Path, gcsPath)
201227
return gcs.WalkAbsolute(ctx, rootPath, recursive, process)
@@ -252,7 +278,7 @@ func (gcs *GCS) GetFileReaderAbsolute(ctx context.Context, key string) (io.ReadC
252278
return nil, err
253279
}
254280
pClient := pClientObj.(*clientObject).Client
255-
obj := pClient.Bucket(gcs.Config.Bucket).Object(key)
281+
obj := gcs.applyEncryption(pClient.Bucket(gcs.Config.Bucket).Object(key))
256282
reader, err := obj.NewReader(ctx)
257283
if err != nil {
258284
if pErr := gcs.clientPool.InvalidateObject(ctx, pClientObj); pErr != nil {
@@ -281,7 +307,7 @@ func (gcs *GCS) PutFileAbsolute(ctx context.Context, key string, r io.ReadCloser
281307
return err
282308
}
283309
pClient := pClientObj.(*clientObject).Client
284-
obj := pClient.Bucket(gcs.Config.Bucket).Object(key)
310+
obj := gcs.applyEncryption(pClient.Bucket(gcs.Config.Bucket).Object(key))
285311
// always retry transient errors to mitigate retry logic bugs.
286312
obj = obj.Retryer(storage.WithPolicy(storage.RetryAlways))
287313
writer := obj.NewWriter(ctx)
@@ -314,7 +340,8 @@ func (gcs *GCS) StatFile(ctx context.Context, key string) (RemoteFile, error) {
314340
}
315341

316342
func (gcs *GCS) StatFileAbsolute(ctx context.Context, key string) (RemoteFile, error) {
317-
objAttr, err := gcs.client.Bucket(gcs.Config.Bucket).Object(key).Attrs(ctx)
343+
obj := gcs.applyEncryption(gcs.client.Bucket(gcs.Config.Bucket).Object(key))
344+
objAttr, err := obj.Attrs(ctx)
318345
if err != nil {
319346
if errors.Is(err, storage.ErrObjectNotExist) {
320347
return nil, ErrNotFound
@@ -369,7 +396,7 @@ func (gcs *GCS) CopyObject(ctx context.Context, srcSize int64, srcBucket, srcKey
369396
}
370397
pClient := pClientObj.(*clientObject).Client
371398
src := pClient.Bucket(srcBucket).Object(srcKey)
372-
dst := pClient.Bucket(gcs.Config.Bucket).Object(dstKey)
399+
dst := gcs.applyEncryption(pClient.Bucket(gcs.Config.Bucket).Object(dstKey))
373400
// always retry transient errors to mitigate retry logic bugs.
374401
dst = dst.Retryer(storage.WithPolicy(storage.RetryAlways))
375402
attrs, err := src.Attrs(ctx)
@@ -379,7 +406,10 @@ func (gcs *GCS) CopyObject(ctx context.Context, srcSize int64, srcBucket, srcKey
379406
}
380407
return 0, err
381408
}
382-
if _, err = dst.CopierFrom(src).Run(ctx); err != nil {
409+
copier := dst.CopierFrom(src)
410+
// If encryption is enabled, the destination will be encrypted
411+
// Note: source objects from object disks are not encrypted by clickhouse-backup
412+
if _, err = copier.Run(ctx); err != nil {
383413
if pErr := gcs.clientPool.InvalidateObject(ctx, pClientObj); pErr != nil {
384414
log.Warn().Msgf("gcs.CopyObject: gcs.clientPool.InvalidateObject error: %+v", pErr)
385415
}

0 commit comments

Comments
 (0)