Articles / Cryptography

  • cryptography
  • go
  • key-rotation
  • key-management
  • aes-gcm
  • backups

Rotating a Key Protects Nothing Until Old Data Is Re-Encrypted

A rotation that only changes which key encrypts new data does nothing for the data you already have. If the old key leaked last month, every row, file and backup written under it is still readable by whoever holds it. The job says "rotated", the dashboard goes green, and the exposure is exactly where it was.

People get this wrong because "rotate" sounds like it fixes things. It doesn't. Rotation is the first of three steps.

What rotation actually changes

Rotation is a promise about the future: new ciphertext uses the new key. That is useful, because it limits how much data any one key ever touches and bounds the damage if a key leaks after the switch.

But old ciphertext is just bytes on disk, and creating a new key doesn't touch them. An attacker with a copy of the database and the old key reads everything written before the switch. A rotation performed after the theft changes nothing for them.

A key only stops mattering when nothing encrypted under it remains anywhere. That includes the places you forgot about.

Where old ciphertext hides

  • The live database or object store, which is the obvious one.
  • Backups and snapshots, which are immutable by design and keep the old key relevant for their whole retention period.
  • Replicas, exports, and the CSV someone dumped to a laptop for a one-off report.
  • Anything cached or queued that was encrypted before the switch.

The backup one is the nasty one. If you keep thirty days of snapshots, the old key is live for thirty more days after you "finish" re-encrypting the main store, and you need to be able to say so honestly.

Step one: make every ciphertext say which key made it

You cannot re-encrypt selectively unless each blob records its key. The cheapest way is a one-byte key ID at the front, bound into the authentication as additional data so it cannot be swapped without detection.

package vault

import (
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"errors"
	"fmt"
)

type Keyring struct {
	Current byte
	Keys    map[byte][]byte // id -> 32-byte AES key
}

func (k *Keyring) aead(id byte) (cipher.AEAD, error) {
	key, ok := k.Keys[id]
	if !ok {
		return nil, fmt.Errorf("key %d is unknown or retired", id)
	}
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	return cipher.NewGCM(block)
}

func (k *Keyring) Seal(plain []byte) ([]byte, error) {
	a, err := k.aead(k.Current)
	if err != nil {
		return nil, err
	}
	nonce := make([]byte, a.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}
	out := append([]byte{k.Current}, nonce...)
	return a.Seal(out, nonce, plain, []byte{k.Current}), nil
}

func (k *Keyring) Open(ct []byte) (plain []byte, id byte, err error) {
	if len(ct) < 1 {
		return nil, 0, errors.New("ciphertext too short")
	}
	id = ct[0]
	a, err := k.aead(id)
	if err != nil {
		return nil, id, err
	}
	n := a.NonceSize()
	if len(ct) < 1+n {
		return nil, id, errors.New("ciphertext too short")
	}
	plain, err = a.Open(nil, ct[1:1+n], ct[1+n:], ct[:1])
	return plain, id, err
}

Keeping a map of keys rather than a single key is the whole trick. The current key encrypts; any key still in the map can decrypt.

Step two: re-encrypt, and make it resumable

Re-encryption is a decrypt-then-seal loop over every record. The useful property is that it is idempotent: anything already under the current key is skipped, so you can stop, crash, and restart without harm.

// Reseal returns a new ciphertext only if ct is not already under
// the current key.
func (k *Keyring) Reseal(ct []byte) (out []byte, changed bool, err error) {
	plain, id, err := k.Open(ct)
	if err != nil {
		return nil, false, err
	}
	if id == k.Current {
		return nil, false, nil
	}
	out, err = k.Seal(plain)
	return out, err == nil, err
}

Run it in batches, write each result back with a compare-and-swap or a version column so a concurrent update is not clobbered, and count how many records are left under each key ID. That count is your progress bar, and later your evidence.

Quick detour: why not just re-encrypt on read?

Lazy re-encryption, where you reseal whenever a record happens to be read, is tempting because it costs nothing extra. It has a hole: cold data is never read, so it never moves.

Use lazy as a helper, not a plan. A background sweep is what actually finishes the job, and it is what lets you retire the key on a date rather than "eventually".

Step three: retire the key, and mean it

When the per-key count hits zero in the live store, you are not done. The key can only be destroyed once the last backup containing it has expired. Until then it stays in the keyring, ideally in a separate, more locked-down place and used for nothing but decryption.

Then delete it. Removing the entry from the map is the point where Open starts returning "retired", which is a useful failure: it tells you something still holds old ciphertext you missed.

A leaked key changes the order of events

If you are rotating because a key is known or suspected compromised, the ordering matters more. Rotation alone leaves the attacker able to read the old data they may already have copied. Re-encryption does not help with copies they already took, either: they hold the old ciphertext and the old key, and always will.

What re-encryption buys you in that case is narrower: the data you still control stops being readable through any further access to the old key, such as a second theft of the store. For what has already gone, the real question is what was in it and whether you have to treat it as disclosed. That is a decision about the data, not the cipher.

Things that quietly break this

  • Wrapped data keys. If you use a key hierarchy, rotating the master key only means re-wrapping the small data keys, which is cheap. Rotating the data keys themselves still means re-encrypting the bulk data.
  • No key ID in the format. Without it you are guessing which key to try, and "try them all" fails loudly or, worse, ambiguously.
  • Nonce limits. With random 96-bit nonces under AES-GCM, rotating also keeps any one key well clear of the point where collisions become a worry. A short rotation interval is a real benefit here, not just ceremony.
  • Derived artefacts. Search indexes, thumbnails and logs built from decrypted plaintext are not covered by any of this.

So the honest definition of a completed rotation is a number: zero records, zero live backups, zero exports under the old key ID. Anything short of that is a new key sitting next to an old problem.