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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. Audit key lifecycle events. Log key creation, rotation, revocation, and destruction events for compliance. Never log the key material itself.

  6. 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]).

  7. 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.