zoobzio December 26, 2025 Edit this page

Encryption

Cereal provides three built-in encryptors and four hashers.

Encryption Boundary Tags

Encryption uses two boundaries:

type User struct {
    // Encrypt when storing to database
    Email string `store.encrypt:"aes"`

    // Decrypt when loading from database
    Email string `load.decrypt:"aes"`

    // Usually paired together
    Email string `store.encrypt:"aes" load.decrypt:"aes"`
}

AES-GCM

Symmetric encryption using AES-256 in GCM mode:

key := make([]byte, 32) // 256-bit key
rand.Read(key)

enc, err := cereal.AES(key)
if err != nil {
    // Key must be 16, 24, or 32 bytes
}

proc, _ := cereal.NewProcessor[User]()
proc.SetEncryptor(cereal.EncryptAES, enc)
  • Requires 16, 24, or 32 byte key (AES-128, AES-192, AES-256)
  • Random nonce prepended to ciphertext
  • Authenticated encryption prevents tampering
  • Base64 encoded in output

RSA-OAEP

Asymmetric encryption for scenarios where encrypt and decrypt happen in different contexts:

// Generate key pair
priv, _ := rsa.GenerateKey(rand.Reader, 2048)
pub := &priv.PublicKey

// Full encryptor (can encrypt and decrypt)
enc := cereal.RSA(pub, priv)

// Encrypt-only encryptor (for client-side)
encryptOnly := cereal.RSA(pub, nil)

proc.SetEncryptor(cereal.EncryptRSA, enc)
  • Uses SHA-256 for OAEP padding
  • Public key encrypts, private key decrypts
  • Pass nil for private key if decrypt not needed

Envelope Encryption

For encrypting large fields or when you need key rotation:

masterKey := make([]byte, 32) // 16, 24, or 32 bytes
rand.Read(masterKey)

enc, err := cereal.Envelope(masterKey)

proc.SetEncryptor(cereal.EncryptEnvelope, enc)

How it works:

  1. Generate a random data encryption key (DEK) per operation
  2. Encrypt the field with the DEK (AES-GCM)
  3. Encrypt the DEK with the master key
  4. Prepend encrypted DEK to ciphertext

Benefits:

  • Master key never touches plaintext directly
  • Key rotation: re-encrypt DEKs, data unchanged
  • Large fields don't stress the master key

Multiple Encryptors

Register different encryptors for different algorithms:

aesEnc, _ := cereal.AES(aesKey)
rsaEnc := cereal.RSA(pub, priv)
envEnc, _ := cereal.Envelope(masterKey)

proc.SetEncryptor(cereal.EncryptAES, aesEnc)
proc.SetEncryptor(cereal.EncryptRSA, rsaEnc)
proc.SetEncryptor(cereal.EncryptEnvelope, envEnc)

type Record struct {
    Email  string `store.encrypt:"aes" load.decrypt:"aes"`       // Uses AES
    Secret string `store.encrypt:"rsa" load.decrypt:"rsa"`       // Uses RSA
    Data   string `store.encrypt:"envelope" load.decrypt:"envelope"` // Uses Envelope
}

Hashing

One-way hashing for fields on the receive boundary:

type User struct {
    Password string `receive.hash:"sha256"`
    Token    string `receive.hash:"argon2"`
}

Hashing applies when receiving external input. Hashed values cannot be recovered.

Built-in Hashers

AlgorithmConstantOutput
SHA-256HashSHA25664 hex chars
SHA-512HashSHA512128 hex chars
Argon2idHashArgon2PHC format string
bcryptHashBcryptbcrypt format string

SHA-256 and SHA-512 are registered by default. Argon2 and bcrypt have configurable parameters.

Argon2 Configuration

// Default parameters
hasher := cereal.Argon2()

// Custom parameters
params := cereal.Argon2Params{
    Time:    3,        // iterations
    Memory:  64 * 1024, // 64MB
    Threads: 4,
    KeyLen:  32,
    SaltLen: 16,
}
hasher := cereal.Argon2WithParams(params)

proc.SetHasher(cereal.HashArgon2, hasher)

bcrypt Configuration

// Default cost (12, per OWASP recommendations)
hasher := cereal.Bcrypt()

// Custom cost
hasher := cereal.BcryptWithCost(10)  // Lower cost for faster tests

proc.SetHasher(cereal.HashBcrypt, hasher)

When to Hash vs Encrypt

Use CaseApproach
Need original value backstore.encrypt + load.decrypt
Verification onlyreceive.hash
Audit trailreceive.hash
Password storagereceive.hash:"argon2" or receive.hash:"bcrypt"

Custom Encryptors

Implement the Encryptor interface:

type Encryptor interface {
    Encrypt(plaintext []byte) ([]byte, error)
    Decrypt(ciphertext []byte) ([]byte, error)
}

Example:

type vaultEncryptor struct {
    client *vault.Client
    path   string
}

func (e *vaultEncryptor) Encrypt(plaintext []byte) ([]byte, error) {
    return e.client.Transit.Encrypt(e.path, plaintext)
}

func (e *vaultEncryptor) Decrypt(ciphertext []byte) ([]byte, error) {
    return e.client.Transit.Decrypt(e.path, ciphertext)
}

proc.SetEncryptor(cereal.EncryptAES, &vaultEncryptor{client, "transit/keys/mykey"})

Custom Hashers

Implement the Hasher interface:

type Hasher interface {
    Hash(data []byte) (string, error)
}

The return value is stored directly (typically hex or PHC format).

type hmacHasher struct {
    key []byte
}

func (h *hmacHasher) Hash(data []byte) (string, error) {
    mac := hmac.New(sha256.New, h.key)
    mac.Write(data)
    return hex.EncodeToString(mac.Sum(nil)), nil
}

proc.SetHasher(cereal.HashSHA256, &hmacHasher{key})

Field Type Support

Tagstring[]byte
store.encryptYesYes
load.decryptYesYes
receive.hashYesYes

String vs Byte Slice Encoding

String fields are base64 encoded after encryption. This ensures compatibility with text-based codecs (JSON, XML, YAML) that cannot represent arbitrary binary data.

Byte slice fields store raw ciphertext without encoding. Binary codecs (MessagePack, BSON) handle this natively.

type Record struct {
    // Base64 encoded in JSON: "email":"SGVsbG8gV29ybGQ..."
    Email string `json:"email" store.encrypt:"aes" load.decrypt:"aes"`

    // Raw bytes in MessagePack/BSON, base64 in JSON
    Data []byte `json:"data" store.encrypt:"aes" load.decrypt:"aes"`
}

Tradeoff: Base64 encoding adds ~33% size overhead (4 bytes output per 3 bytes input). For performance-critical paths with large encrypted fields:

  1. Use []byte fields with binary codecs (MessagePack, BSON)
  2. Implement Encryptable/Decryptable interfaces with custom encoding
  3. Store encrypted data separately from the serialized struct