From 39bcbbc359df57faabc9374ee79a1ced16f22576 Mon Sep 17 00:00:00 2001 From: "Giau. Tran Minh" Date: Tue, 5 May 2026 10:47:36 +0000 Subject: [PATCH] encryption --- entc/integration/blob/encrypt.go | 140 +++++++++++++++++++++++++++++++ entc/integration/blob_test.go | 86 +++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 entc/integration/blob/encrypt.go diff --git a/entc/integration/blob/encrypt.go b/entc/integration/blob/encrypt.go new file mode 100644 index 000000000..fee64e364 --- /dev/null +++ b/entc/integration/blob/encrypt.go @@ -0,0 +1,140 @@ +// Copyright 2019-present Facebook Inc. All rights reserved. +// This source code is licensed under the Apache 2.0 license found +// in the LICENSE file in the root directory of this source tree. + +package blob + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "errors" + "fmt" + "io" +) + +// tenantKey is the context key for the tenant name. +type tenantKey struct{} + +// WithTenant returns a context carrying the given tenant name. +func WithTenant(ctx context.Context, tenant string) context.Context { + return context.WithValue(ctx, tenantKey{}, tenant) +} + +// TenantFrom extracts the tenant name from the context. +// Returns "" if no tenant is set. +func TenantFrom(ctx context.Context) string { + v, _ := ctx.Value(tenantKey{}).(string) + return v +} + +// Encrypted wraps a GoBucket and transparently encrypts/decrypts blob data +// using AES-CTR with a per-tenant derived key. +// +// The encryption key is derived from the tenant name (from context) combined +// with a master seed using SHA-256. This ensures that data written by one +// tenant cannot be decrypted by another tenant. +// +// A random IV is prepended to the ciphertext on write and read back on open. +// +// Usage: +// +// bucket, _ := blob.OpenBucket(ctx, url) +// enc := blob.NewEncrypted(bucket, masterSeed) +// // Use enc as the Blob implementation in BlobOpeners. +// // Ensure the context carries the tenant: blob.WithTenant(ctx, "acme") +type Encrypted struct { + inner *GoBucket + seed []byte +} + +// NewEncrypted creates an encrypting wrapper around the given bucket. +// The seed is combined with the tenant name (from context) at each operation +// to derive a per-tenant AES-256 key via SHA-256. +func NewEncrypted(bucket *GoBucket, seed []byte) *Encrypted { + return &Encrypted{inner: bucket, seed: seed} +} + +// deriveKey produces a 32-byte AES-256 key from the tenant + seed. +func (e *Encrypted) deriveKey(tenant string) (cipher.Block, error) { + if tenant == "" { + return nil, errors.New("blob: encryption requires a tenant in context (use blob.WithTenant)") + } + h := sha256.New() + h.Write([]byte(tenant)) + h.Write(e.seed) + b, err := aes.NewCipher(h.Sum(nil)) // 32 bytes → AES-256 + if err != nil { + return nil, fmt.Errorf("blob: deriving encryption key: %w", err) + } + return b, nil +} + +// NewReader opens and decrypts the blob at key. The first [aes.BlockSize] bytes +// are treated as the IV; the rest is decrypted with AES-CTR using the +// tenant-derived key. +// Returns [fs.ErrNotExist] if the key does not exist. +func (e *Encrypted) NewReader(ctx context.Context, key string) (io.ReadCloser, error) { + block, err := e.deriveKey(TenantFrom(ctx)) + if err != nil { + return nil, err + } + rc, err := e.inner.NewReader(ctx, key) + if err != nil { + return nil, err + } + iv := make([]byte, aes.BlockSize) + switch _, err := io.ReadFull(rc, iv); { + case err == io.EOF, err == io.ErrUnexpectedEOF: + return nil, errors.Join(fmt.Errorf("blob: ciphertext too short for key %q", key), rc.Close()) + case err != nil: + return nil, errors.Join(err, rc.Close()) + } + return &decryptionReader{ + Reader: cipher.StreamReader{S: cipher.NewCTR(block, iv), R: rc}, + closer: rc, + }, nil +} + +// NewWriter opens an encrypting writer for the blob at key. A random IV is +// written first, followed by AES-CTR encrypted data using the tenant-derived key. +func (e *Encrypted) NewWriter(ctx context.Context, key string) (io.WriteCloser, error) { + block, err := e.deriveKey(TenantFrom(ctx)) + if err != nil { + return nil, err + } + wc, err := e.inner.NewWriter(ctx, key) + if err != nil { + return nil, err + } + iv := make([]byte, aes.BlockSize) + if _, err := rand.Read(iv); err != nil { + return nil, errors.Join(fmt.Errorf("blob: generating IV: %w", err), wc.Close()) + } + if _, err := wc.Write(iv); err != nil { + return nil, errors.Join(fmt.Errorf("blob: writing IV: %w", err), wc.Close()) + } + return cipher.StreamWriter{S: cipher.NewCTR(block, iv), W: wc}, nil +} + +// Delete removes the blob stored at key. +func (e *Encrypted) Delete(ctx context.Context, key string) error { + return e.inner.Delete(ctx, key) +} + +// Close releases the underlying bucket resources. +func (e *Encrypted) Close() error { + return e.inner.Close() +} + +// decryptionReader decrypts on Read and closes the underlying storage decryptionReader. +type decryptionReader struct { + io.Reader + closer io.Closer +} + +func (r *decryptionReader) Close() error { + return r.closer.Close() +} diff --git a/entc/integration/blob_test.go b/entc/integration/blob_test.go index f014e50c6..3a6013dee 100644 --- a/entc/integration/blob_test.go +++ b/entc/integration/blob_test.go @@ -418,6 +418,92 @@ func TestBlobWriterOptionsApplied(t *testing.T) { require.Equal(t, v2, got) } +func TestBlobEncryption(t *testing.T) { + // Demonstrate per-tenant encryption using a master seed + tenant from context. + // Each tenant derives a unique AES-256 key via SHA-256(tenant || seed). + // Data written by one tenant cannot be decrypted by another. + dir := blobDir(t) + + // Master seed shared across all tenants (kept secret server-side). + masterSeed := []byte("super-secret-master-seed-for-test") + + encryptedOpeners := ent.BlobOpeners{ + Document: func(ctx context.Context, field string) (ent.Blob, error) { + var subdir string + switch field { + case document.FieldContent: + subdir = "documents" + case document.FieldThumbnail: + subdir = "thumbnails" + default: + return nil, fmt.Errorf("unknown blob field: %s", field) + } + b, err := blob.OpenBucket(ctx, "file://"+filepath.Join(dir, subdir)) + if err != nil { + return nil, err + } + // Wrap the bucket with per-tenant encryption. + return blob.NewEncrypted(b, masterSeed), nil + }, + } + + // Tenant "acme" writes encrypted data. + client, _, _ := setupBlob(t, ent.WithBlobOpeners(encryptedOpeners)) + acmeCtx := blob.WithTenant(context.Background(), "acme") + + plaintext := []byte("top secret document content for acme") + doc := client.Document.Create(). + SetName("encrypted-doc"). + SetContent(bytes.NewReader(plaintext)). + SetThumbnail(bytes.NewReader([]byte("secret-thumb"))). + SaveX(acmeCtx) + + // Reading with the same tenant returns decrypted plaintext. + got := blobContent(t, doc.Content, acmeCtx) + require.Equal(t, plaintext, got) + + gotThumb := blobContent(t, doc.Thumbnail, acmeCtx) + require.Equal(t, []byte("secret-thumb"), gotThumb) + + // Verify the raw file on disk is NOT plaintext (it's encrypted). + contentKey, err := doc.ContentKey(acmeCtx) + require.NoError(t, err) + rawPath := filepath.Join(dir, "documents", contentKey) + raw, err := os.ReadFile(rawPath) + require.NoError(t, err) + require.NotEqual(t, plaintext, raw, "raw data on disk should be encrypted") + // The raw data should be longer than plaintext (16-byte IV prepended). + require.Equal(t, len(plaintext)+16, len(raw)) + + // A different tenant ("evil") cannot decrypt acme's data — the derived key + // differs, so AES-CTR produces garbage (not the original plaintext). + evilCtx := blob.WithTenant(context.Background(), "evil") + evilData := blobContent(t, doc.Content, evilCtx) + require.NotEqual(t, plaintext, evilData, "different tenant must not read acme's plaintext") + + // No tenant in context → error. + noTenantCtx := context.Background() + _, err = doc.Content(noTenantCtx) + require.Error(t, err) + require.Contains(t, err.Error(), "requires a tenant") + + // Update works through encryption too. + v2 := []byte("updated secret content") + doc = doc.Update().SetContent(bytes.NewReader(v2)).SaveX(acmeCtx) + got = blobContent(t, doc.Content, acmeCtx) + require.Equal(t, v2, got) + + // ContentWriter also encrypts with tenant key. + w, err := doc.ContentWriter(acmeCtx) + require.NoError(t, err) + _, err = w.Write([]byte("written via writer")) + require.NoError(t, err) + require.NoError(t, w.Close()) + + got = blobContent(t, doc.Content, acmeCtx) + require.Equal(t, []byte("written via writer"), got) +} + func TestBlobPrefix(t *testing.T) { dir := blobDir(t) openers := newBlobOpeners(dir)