Encryption¶
Overview¶
Events in an event-sourced system are immutable — once written, they cannot be modified or deleted. This creates a fundamental tension with data protection requirements:
- GDPR and CCPA require the ability to delete personal data on request.
- PII in events (emails, names, addresses) persists forever unless you plan for it.
- Credentials and secrets stored in events are exposed to anyone with database access.
The encryption library solves this with envelope encryption and crypto-shredding:
- Envelope encryption encrypts data with a per-entity key (DEK), and encrypts that key with a system key (KEK). Data stays encrypted at rest while allowing key rotation.
- Crypto-shredding makes all of an entity's encrypted data permanently unreadable by destroying its encryption key — no need to find and delete individual records.
Installation¶
go get github.com/pupsourcing/encryption
The library depends on Go 1.24+ and PostgreSQL 12+ (for the built-in key store).
Architecture¶
Two-Tier Key Hierarchy¶
System Key (KEK) ─── 32-byte AES key, stored outside the database
│
└─→ Encrypts Data Encryption Keys (DEKs)
│
└─→ Encrypt application plaintext
System Keys (Key Encryption Keys): Master keys stored securely outside the database — in files, environment variables, or a secrets manager. They never touch the database.
Data Encryption Keys: Per-scope keys stored encrypted in the database. Each entity (user, tenant, etc.) gets its own DEK. The DEK is encrypted with the active system key before storage.
Encryption Flow¶
Plaintext
│
▼
Encrypt with DEK ──→ Ciphertext (stored in events)
│
DEK
│
▼
Encrypt with KEK ──→ Encrypted DEK (stored in key store)
Decryption Flow¶
Encrypted DEK (from key store)
│
▼
Decrypt with KEK ──→ DEK
│
Ciphertext (from events)
│
▼
Decrypt with DEK ──→ Plaintext
The default cipher is AES-256-GCM — authenticated encryption that provides both confidentiality and integrity.
Getting Started¶
1. Set Up a System Keyring¶
The keyring holds your system keys (KEKs). For development and testing, create one in memory:
import "github.com/pupsourcing/encryption/systemkey"
keyring := systemkey.NewKeyring(
map[string][]byte{
"key-2024": []byte("0123456789abcdef0123456789abcdef"), // 32 bytes
},
"key-2024", // active key ID
)
For production, load keys from files:
keyring, err := systemkey.NewKeyringFromFiles(systemkey.FileKeyConfig{
// Configure file paths and active key ID
})
2. Set Up the Key Store¶
The key store persists encrypted DEKs. Use the built-in PostgreSQL adapter:
import "github.com/pupsourcing/encryption/keystore/postgres"
keyStore := postgres.NewKeyStore(db)
Run the provided migration to create the key store table in your database.
3. Create the Encryption Module¶
import "github.com/pupsourcing/encryption"
module := encryption.NewWithDefaults(keyring, keyStore)
This gives you access to:
module.KeyManager— Create, rotate, revoke, and destroy keys.module.Envelope— Low-level encrypt and decrypt operations.module.Hasher— Deterministic hashing for lookups.
4. Encrypt and Decrypt¶
ctx := context.Background()
// Create a key for a user
_, err := module.KeyManager.CreateKey(ctx, "user", "user-123")
// Encrypt
ciphertext, version, err := module.Envelope.Encrypt(ctx, "user", "user-123", "sensitive data")
// Decrypt
plaintext, err := module.Envelope.Decrypt(ctx, "user", "user-123", ciphertext, version)
// plaintext == "sensitive data"
PII Encryption¶
The pii adapter is designed for protecting personally identifiable information in events. It uses a single key per subject (no rotation) and supports crypto-shredding.
Setup¶
import "github.com/pupsourcing/encryption/pii"
adapter := pii.NewAdapter[UserID](module.Envelope, "user")
The scope parameter ("user") groups keys by entity type. The generic parameter must implement fmt.Stringer.
Encrypting PII in Events¶
Encrypt PII fields before including them in domain events:
func (h *Handler) RegisterUser(ctx context.Context, cmd RegisterUserCommand) error {
// Create encryption key for this user (do this once, at registration)
_, err := module.KeyManager.CreateKey(ctx, "user", cmd.UserID.String())
if err != nil {
return err
}
// Encrypt PII fields
encryptedEmail, err := adapter.Encrypt(ctx, cmd.UserID, cmd.Email)
if err != nil {
return err
}
encryptedName, err := adapter.Encrypt(ctx, cmd.UserID, cmd.Name)
if err != nil {
return err
}
// Use encrypted values in domain events
event := v1.UserRegistered{
Email: string(encryptedEmail),
Name: string(encryptedName),
}
// ... append to event store
}
Decrypting PII¶
func (p *UserProjection) onUserRegistered(ctx context.Context, pe store.PersistedEvent, e v1.UserRegistered) error {
email, err := adapter.Decrypt(ctx, userID, pii.EncryptedValue(e.Email))
if err != nil {
return err
}
name, err := adapter.Decrypt(ctx, userID, pii.EncryptedValue(e.Name))
if err != nil {
return err
}
// Use decrypted values to update read model
_, err = p.db.ExecContext(ctx,
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`,
pe.AggregateID, email, name,
)
return err
}
Crypto-Shredding¶
When a user requests account deletion, destroy their encryption key. All their encrypted data becomes permanently unreadable:
func (h *Handler) DeleteUser(ctx context.Context, cmd DeleteUserCommand) error {
// Destroy all encryption keys for this user
err := module.KeyManager.DestroyKeys(ctx, "user", cmd.UserID.String())
if err != nil {
return err
}
// Append a deletion event
// ...
}
After key destruction, any attempt to decrypt the user's data returns an error. Projections can detect this and display [REDACTED]:
email, err := adapter.Decrypt(ctx, userID, pii.EncryptedValue(e.Email))
if err != nil {
// Key was destroyed — data is crypto-shredded
email = string(pii.Redacted) // "[REDACTED]"
}
The EncryptedValue Type¶
The PII adapter uses a dedicated EncryptedValue type (a string alias) to make encrypted data explicit in your code:
type EncryptedValue string
const Redacted = EncryptedValue("[REDACTED]")
Secret Encryption¶
The secret adapter is designed for credentials, API keys, tokens, and other secrets that may need key rotation. Unlike the PII adapter, it tracks key versions.
Setup¶
import "github.com/pupsourcing/encryption/secret"
adapter := secret.NewAdapter(module.Envelope)
Encrypting Secrets¶
encrypted, err := adapter.Encrypt(ctx, "api-credentials", "integration-123", "sk_live_abc123")
// encrypted.Content = base64-encoded ciphertext
// encrypted.KeyVersion = 1
Decrypting Secrets¶
plaintext, err := adapter.Decrypt(ctx, "api-credentials", "integration-123", encrypted)
// plaintext == "sk_live_abc123"
Key Rotation¶
Rotate keys when needed. Old ciphertext remains decryptable (the old DEK is still stored, encrypted with the system key):
// Rotate the key — creates a new version
newVersion, err := module.KeyManager.RotateKey(ctx, "api-credentials", "integration-123")
// newVersion == 2
// New encryptions use the new key version
newEncrypted, err := adapter.Encrypt(ctx, "api-credentials", "integration-123", "sk_live_xyz789")
// newEncrypted.KeyVersion == 2
// Old ciphertext is still decryptable
oldPlaintext, err := adapter.Decrypt(ctx, "api-credentials", "integration-123", oldEncrypted)
// oldPlaintext == "sk_live_abc123" (decrypted with key version 1)
The EncryptedValue Type¶
The secret adapter's EncryptedValue includes the key version used for encryption:
type EncryptedValue struct {
Content string
KeyVersion int
}
Store both fields alongside the encrypted data so decryption knows which key version to use.
Deterministic Hashing¶
The HMAC hasher produces deterministic, non-reversible hashes for sensitive values. Use it for lookups (e.g., "find user by email") without storing plaintext.
Setup¶
import "github.com/pupsourcing/encryption/hash"
// Key must be exactly 32 bytes
hasher := hash.NewHMACHasher([]byte("0123456789abcdef0123456789abcdef"))
Or use the module's built-in hasher:
module := encryption.NewWithDefaults(keyring, keyStore,
encryption.WithHMACKey([]byte("0123456789abcdef0123456789abcdef")),
)
hasher := module.Hasher
Hashing for Lookups¶
emailHash := hasher.Hash("alice@example.com")
// emailHash is a hex-encoded HMAC-SHA256 string
// Store the hash in a lookup table
_, err := db.ExecContext(ctx,
`INSERT INTO user_email_lookup (email_hash, user_id) VALUES ($1, $2)`,
emailHash, userID,
)
// Look up by email without storing plaintext
lookupHash := hasher.Hash("alice@example.com")
var userID string
err := db.QueryRowContext(ctx,
`SELECT user_id FROM user_email_lookup WHERE email_hash = $1`,
lookupHash,
).Scan(&userID)
Use in Unique Constraints¶
Hash-based lookups work well with database unique constraints:
CREATE TABLE user_email_lookup (
email_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL
);
This enforces email uniqueness without storing plaintext emails in the lookup table.
Transaction Integration¶
Key operations can participate in the same database transaction as event persistence. This ensures atomicity — if the event append fails, the key creation is rolled back too.
Using keystore.WithTx¶
Pass the active transaction to the key store via context:
import "github.com/pupsourcing/encryption/keystore"
func (h *Handler) RegisterUser(ctx context.Context, cmd RegisterUserCommand) error {
tx, err := h.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
// Attach the transaction to the context
ctx = keystore.WithTx(ctx, tx)
// Create the encryption key (uses the transaction)
_, err = module.KeyManager.CreateKey(ctx, "user", cmd.UserID)
if err != nil {
return err
}
// Encrypt PII
encryptedEmail, err := adapter.Encrypt(ctx, cmd.UserID, cmd.Email)
if err != nil {
return err
}
// Build and append events (within the same transaction)
storeEvents, err := persistence.ToESEvents("User", cmd.UserID, []any{
v1.UserRegistered{Email: string(encryptedEmail)},
})
if err != nil {
return err
}
_, err = h.eventStore.AppendEventsWithTx(ctx, tx, "User", cmd.UserID, storeEvents, store.AppendCondition{})
if err != nil {
return err
}
// Commit — key creation and event append are atomic
return tx.Commit()
}
Configuration¶
Module Constructors¶
Full control:
module := encryption.New(encryption.Config{
Keyring: keyring,
Store: keyStore,
Cipher: customCipher, // optional, defaults to AES-256-GCM
})
With defaults (recommended):
module := encryption.NewWithDefaults(keyring, keyStore,
encryption.WithCipher(customCipher), // optional
encryption.WithHMACKey(hmacKey), // optional, enables hasher
)
System Key Management¶
In-memory keyring (development/testing):
keyring := systemkey.NewKeyring(
map[string][]byte{
"key-1": []byte("0123456789abcdef0123456789abcdef"),
},
"key-1",
)
File-based keyring (production):
keyring, err := systemkey.NewKeyringFromFiles(systemkey.FileKeyConfig{
// Configure paths to key files and active key ID
})
Key Store¶
PostgreSQL (built-in):
import "github.com/pupsourcing/encryption/keystore/postgres"
keyStore := postgres.NewKeyStore(db)
Pluggable Components¶
Every major component is defined by an interface, so you can replace any part of the encryption stack.
Cipher Interface¶
Bring your own encryption algorithm:
type Cipher interface {
Encrypt(key, plaintext []byte) ([]byte, error)
Decrypt(key, ciphertext []byte) ([]byte, error)
KeySize() int
}
The default implementation uses AES-256-GCM from the cipher/aesgcm package.
KeyStore Interface¶
Bring your own key storage backend:
type KeyStore interface {
GetActiveKey(ctx context.Context, scope, scopeID string) (*EncryptedKey, error)
GetKey(ctx context.Context, scope, scopeID string, version int) (*EncryptedKey, error)
CreateKey(ctx context.Context, scope, scopeID string, version int, encryptedDEK []byte, systemKeyID string) error
RevokeKeys(ctx context.Context, scope, scopeID string) error
DestroyKeys(ctx context.Context, scope, scopeID string) error
}
Keyring Interface¶
Bring your own system key management (e.g., HashiCorp Vault, AWS KMS):
type Keyring interface {
ActiveKey() (key []byte, keyID string)
Key(keyID string) ([]byte, error)
}
Security Considerations¶
-
Store system keys outside the database. System keys must never be persisted in the same database as encrypted data. Use files with restricted permissions, environment variables, or a dedicated secrets manager.
-
Use separate scopes for different data categories. Encrypt user PII under the
"user"scope and API credentials under"api-credentials". This limits the blast radius of a key compromise. -
Rotate system keys periodically. The keyring supports multiple system keys. Add a new key, set it as active, and new DEKs will be encrypted with it. Old DEKs remain decryptable with the old system key.
-
Sensitive data is zeroed from memory. The library provides
encryption.ZeroBytes(b)to clear byte slices containing sensitive data. DEKs are automatically zeroed after use internally. -
Audit key lifecycle events. Log key creation, rotation, revocation, and destruction events for compliance. Never log the key material itself.
-
Handle destroyed keys gracefully. After crypto-shredding, decryption attempts will fail. Design your projections and read models to handle this case (e.g., display
[REDACTED]). -
Test with real encryption in integration tests. Use the in-memory keyring for tests, but don't skip encryption entirely — this ensures your encrypt/decrypt paths work end to end.