mirror of
https://github.com/ent/ent.git
synced 2026-05-22 09:31:45 +03:00
encryption
This commit is contained in:
140
entc/integration/blob/encrypt.go
Normal file
140
entc/integration/blob/encrypt.go
Normal file
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user