Key Rotation
Cereal's SetEncryptor method allows runtime key changes, but doesn't track which key encrypted which data. This guide covers patterns for managing key rotation.
The Challenge
When you rotate keys, existing encrypted data becomes unreadable:
// Original key
enc1, _ := cereal.AES(key1)
proc.SetEncryptor(cereal.EncryptAES, enc1)
stored, _ := proc.Write(ctx, &user) // Encrypted with key1
// Rotate key
enc2, _ := cereal.AES(key2)
proc.SetEncryptor(cereal.EncryptAES, enc2)
loaded, _ := proc.Read(ctx, stored) // Fails: can't decrypt with key2
Pattern 1: Key Versioning Wrapper
Embed key version in ciphertext. Decrypt tries keys in order.
type versionedEncryptor struct {
current cereal.Encryptor
currentVer byte
previous map[byte]cereal.Encryptor
}
func (e *versionedEncryptor) Encrypt(plaintext []byte) ([]byte, error) {
ciphertext, err := e.current.Encrypt(plaintext)
if err != nil {
return nil, err
}
// Prepend version byte
return append([]byte{e.currentVer}, ciphertext...), nil
}
func (e *versionedEncryptor) Decrypt(ciphertext []byte) ([]byte, error) {
if len(ciphertext) < 1 {
return nil, fmt.Errorf("ciphertext too short")
}
version := ciphertext[0]
data := ciphertext[1:]
// Try versioned key
if version == e.currentVer {
return e.current.Decrypt(data)
}
if enc, ok := e.previous[version]; ok {
return enc.Decrypt(data)
}
return nil, fmt.Errorf("unknown key version: %d", version)
}
// Usage
func NewVersionedEncryptor(currentKey []byte, currentVer byte) *versionedEncryptor {
enc, _ := cereal.AES(currentKey)
return &versionedEncryptor{
current: enc,
currentVer: currentVer,
previous: make(map[byte]cereal.Encryptor),
}
}
func (e *versionedEncryptor) AddPreviousKey(key []byte, version byte) error {
enc, err := cereal.AES(key)
if err != nil {
return err
}
e.previous[version] = enc
return nil
}
Register and rotate:
venc := NewVersionedEncryptor(currentKey, 2)
venc.AddPreviousKey(oldKey, 1)
proc.SetEncryptor(cereal.EncryptAES, venc)
// All data decrypts regardless of which key version encrypted it
// New encryptions use version 2
Pattern 2: Envelope Encryption
Use cereal.Envelope for built-in key separation. The master key encrypts data keys, not data directly.
masterKey := loadMasterKey() // From KMS, vault, etc.
enc, _ := cereal.Envelope(masterKey)
proc.SetEncryptor(cereal.EncryptEnvelope, enc)
Rotation strategy:
- Each record has its own data encryption key (DEK)
- DEK is encrypted by master key and stored with the ciphertext
- To rotate: re-encrypt DEKs with new master key (data unchanged)
// Re-encryption function for envelope rotation
func ReencryptEnvelope(oldMaster, newMaster []byte, ciphertext []byte) ([]byte, error) {
oldEnc, _ := cereal.Envelope(oldMaster)
newEnc, _ := cereal.Envelope(newMaster)
// Decrypt with old master (recovers DEK and plaintext)
plaintext, err := oldEnc.Decrypt(ciphertext)
if err != nil {
return nil, err
}
// Re-encrypt with new master (new DEK)
return newEnc.Encrypt(plaintext)
}
Pattern 3: External Key Management
Delegate to a KMS that handles versioning internally.
type kmsEncryptor struct {
client *kms.Client
keyID string
}
func (e *kmsEncryptor) Encrypt(plaintext []byte) ([]byte, error) {
// KMS tracks key versions internally
return e.client.Encrypt(e.keyID, plaintext)
}
func (e *kmsEncryptor) Decrypt(ciphertext []byte) ([]byte, error) {
// KMS routes to correct key version based on ciphertext metadata
return e.client.Decrypt(e.keyID, ciphertext)
}
Works with:
- AWS KMS
- Google Cloud KMS
- Azure Key Vault
- HashiCorp Vault Transit
Pattern 4: Decryptable Interface
For complex rotation logic, implement Decryptable:
func (u *User) Decrypt(encryptors map[cereal.EncryptAlgo]cereal.Encryptor) error {
// Try current key
enc := encryptors[cereal.EncryptAES]
ciphertext, _ := base64.StdEncoding.DecodeString(u.Email)
plaintext, err := enc.Decrypt(ciphertext)
if err == nil {
u.Email = string(plaintext)
return nil
}
// Fall back to legacy key (stored separately)
legacyEnc := encryptors[cereal.EncryptAlgo("aes-legacy")]
plaintext, err = legacyEnc.Decrypt(ciphertext)
if err != nil {
return fmt.Errorf("decrypt failed with all keys: %w", err)
}
u.Email = string(plaintext)
return nil
}
Register both:
proc.SetEncryptor(cereal.EncryptAES, currentEnc)
proc.SetEncryptor(cereal.EncryptAlgo("aes-legacy"), legacyEnc)
Rotation Workflow
Regardless of pattern, the workflow is:
- Prepare - Load new key, keep old key available
- Dual-read - Configure decryption to try both keys
- Re-encrypt - Background job re-encrypts with new key
- Verify - Confirm all data uses new key
- Retire - Remove old key from configuration
// Step 1-2: Dual-read configuration
venc := NewVersionedEncryptor(newKey, 2)
venc.AddPreviousKey(oldKey, 1)
proc.SetEncryptor(cereal.EncryptAES, venc)
// Step 3: Re-encryption job
for record := range records {
loaded, _ := proc.Read(ctx, record.Data)
updated, _ := proc.Write(ctx, loaded) // Re-encrypts with new key
record.Data = updated
save(record)
}
// Step 5: Remove old key
venc.previous = nil // Or create new encryptor without legacy
Considerations
Storage overhead: Version bytes add 1 byte per field. Envelope encryption adds ~60 bytes (encrypted DEK + nonce).
Performance: KMS calls add latency. Consider caching or local key unwrapping for high-throughput scenarios.
Audit: Log key rotation events. Track which key version encrypted each record if compliance requires it.
Backup keys: Encrypted backups become unreadable if you lose the key. Maintain secure key backups or use KMS with key history.