zoobzio December 26, 2025 Edit this page

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:

  1. Each record has its own data encryption key (DEK)
  2. DEK is encrypted by master key and stored with the ciphertext
  3. 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:

  1. Prepare - Load new key, keep old key available
  2. Dual-read - Configure decryption to try both keys
  3. Re-encrypt - Background job re-encrypts with new key
  4. Verify - Confirm all data uses new key
  5. 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.