Blog / Cryptography

  • go
  • cryptography
  • envelope-encryption
  • aes-gcm
  • key-management
  • key-rotation

Envelope Encryption in Go Without a Cloud KMS

Rotating the one key that encrypts everything means re-encrypting everything. Envelope encryption avoids that: each record gets its own random data key (DEK), and a master key (KEK) only ever encrypts those small DEKs. Rotate the KEK and you rewrap a few dozen bytes per record, leaving the bulk data alone.

Cloud KMS products sell this pattern, but the pattern itself needs nothing except AES-GCM and a random number generator. Here is a version in about sixty lines of standard-library Go.

The shape of an envelope

Each stored record carries three things:

  • the ID of the KEK that wrapped the DEK
  • the DEK, encrypted under that KEK
  • the data, encrypted under the DEK

Decrypting reverses it: look up the KEK by ID, unwrap the DEK, decrypt the data. The KEK never touches the data, and the DEK never leaves the record in plaintext.

The code

One seal and one open helper do all the cryptography. Both prepend or strip a random nonce, so a blob is self-contained.

package envelope

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

type Envelope struct {
	KeyID      string `json:"kid"`
	WrappedDEK []byte `json:"wdek"` // nonce || ciphertext || tag
	Data       []byte `json:"data"` // nonce || ciphertext || tag
}

// Keyring maps key IDs to 32-byte KEKs. Current is used for new envelopes.
type Keyring struct {
	Current string
	Keys    map[string][]byte
}

func seal(key, plaintext, aad []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}
	return gcm.Seal(nonce, nonce, plaintext, aad), nil
}

func open(key, blob, aad []byte) ([]byte, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, err
	}
	if len(blob) < gcm.NonceSize() {
		return nil, errors.New("envelope: blob too short")
	}
	n := gcm.NonceSize()
	return gcm.Open(nil, blob[:n], blob[n:], aad)
}

Now the envelope operations themselves.

func (kr *Keyring) Encrypt(plaintext, aad []byte) (*Envelope, error) {
	dek := make([]byte, 32)
	if _, err := rand.Read(dek); err != nil {
		return nil, err
	}
	wrapped, err := seal(kr.Keys[kr.Current], dek, []byte(kr.Current))
	if err != nil {
		return nil, err
	}
	data, err := seal(dek, plaintext, aad)
	if err != nil {
		return nil, err
	}
	return &Envelope{KeyID: kr.Current, WrappedDEK: wrapped, Data: data}, nil
}

func (kr *Keyring) Decrypt(e *Envelope, aad []byte) ([]byte, error) {
	kek, ok := kr.Keys[e.KeyID]
	if !ok {
		return nil, fmt.Errorf("envelope: unknown key %q", e.KeyID)
	}
	dek, err := open(kek, e.WrappedDEK, []byte(e.KeyID))
	if err != nil {
		return nil, fmt.Errorf("envelope: unwrap: %w", err)
	}
	return open(dek, e.Data, aad)
}

// Rewrap moves an envelope to the current KEK without touching Data.
func (kr *Keyring) Rewrap(e *Envelope) error {
	kek, ok := kr.Keys[e.KeyID]
	if !ok {
		return fmt.Errorf("envelope: unknown key %q", e.KeyID)
	}
	dek, err := open(kek, e.WrappedDEK, []byte(e.KeyID))
	if err != nil {
		return err
	}
	wrapped, err := seal(kr.Keys[kr.Current], dek, []byte(kr.Current))
	if err != nil {
		return err
	}
	e.KeyID, e.WrappedDEK = kr.Current, wrapped
	return nil
}

Why the AAD arguments matter

The wrap uses the key ID as additional authenticated data (AAD). That ties the wrapped DEK to the KEK label it claims, so an attacker who edits kid in storage gets an authentication failure rather than a confusing wrong-key path.

For the data, pass something that identifies the record: a row ID, a tenant, a filename. Without record-bound AAD, someone with write access to storage can copy a valid envelope from one row to another and it will decrypt happily. With it, the swap fails the GCM tag check.

The AAD is not stored; the caller must supply the same value on decrypt. That is the point.

Quick detour: why random nonces are fine here

Random 96-bit GCM nonces get nervous people twitching, and reusing one under a key is catastrophic. But look at where the keys sit. The DEK encrypts exactly one message, so its nonce can never collide with anything. The KEK, though, wraps one DEK per record, so it is the key to watch.

NIST guidance for random nonces in GCM caps invocations under a single key at 232. That is around four billion wraps per KEK. For most systems, rotating the KEK long before then is easy, and you were going to rotate anyway.

Rotating

Add a new key, switch Current, then walk the records:

kr.Keys["2026-10"] = newKey // 32 random bytes from your secret store
kr.Current = "2026-10"

for _, e := range allEnvelopes {
	if e.KeyID == kr.Current {
		continue
	}
	if err := kr.Rewrap(e); err != nil {
		return err
	}
	// persist e
}

Reads keep working throughout, because old IDs stay in the keyring until every envelope has moved. Only delete the old KEK once a full pass reports nothing left on it. And the job is resumable: stop halfway and the skip check picks up where it left off.

What you give up compared with a real KMS

The honest list:

  • The KEK lives in your process memory. A KMS keeps it in an HSM and only returns unwrapped DEKs; here, anyone who reads your process or your config reads the KEK.
  • There is no audit trail of unwrap calls unless you write one.
  • Getting 32 good bytes to the process is your problem: a file with mode 0400, a systemd credential, or a key split across people.
  • Rewrapping does not help if the old KEK leaked. An attacker holding it and an old copy of the envelope can still unwrap the DEK from that copy.

That last one is the real limit of the technique. Rotation protects future storage; a leaked KEK means you must re-encrypt the data itself under fresh DEKs, and the envelope design makes that no worse, but no cheaper either.

A small check worth keeping

A round-trip test catches most wiring mistakes. Encrypt, rotate, rewrap, then decrypt with only the new key in the ring and confirm the plaintext survives, and that a different AAD fails. If both pass, the structure is doing its job.