Why Your Wrapped Data Key Is Useless Without Its Context Binding
Here is a bug that passes every unit test you would naturally write. You wrap a per-record data key with a master key, store the wrapped blob next to the ciphertext, and unwrap it on read. Everything round-trips. Then someone with write access to your database copies one row's blob over another's, and your service cheerfully decrypts the wrong person's data for the wrong person.
The master key was never stolen. The wrapping was fine. What was missing is any statement of which record this key belongs to.
The attack needs no key at all
Suppose a table holds id, owner, wrapped_dek and ciphertext. The master key (the KEK) lives in the application, never in the database. So the database is "encrypted", and a stolen dump is useless.
But a database writer is a different threat from a database reader. Think SQL injection with an UPDATE, a compromised reporting job, or a careless admin. That person cannot decrypt anything. They can, however, do this:
- Pick a victim row (say owner
bob) and an attacker-controlled row (ownermallory). - Copy Bob's
wrapped_dekandciphertextinto Mallory's row. - Log in as Mallory and read her record.
The service unwraps the key with the KEK, which works, because Bob's blob is a perfectly valid wrapping. It decrypts the data, which works, and hands Bob's plaintext to Mallory. The service is a confused deputy: it holds the key and does what the data tells it to.
The naive version
This is what most first attempts look like. It is correct as far as it goes, and that is the trouble.
func wrapNaive(kek, dek []byte) ([]byte, error) {
block, err := aes.NewCipher(kek)
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
}
// nil additional data: nothing ties this blob to anything
return gcm.Seal(nonce, nonce, dek, nil), nil
}
GCM authenticates the ciphertext, so tampering with the blob is caught. Moving the blob intact to another row is not tampering as far as GCM can tell. It is still a valid message under the same key.
Bind the key to its context
AEAD modes take associated data: bytes that are authenticated but not encrypted. If the unwrap supplies different bytes from the wrap, the tag check fails. So we put the record's identity in there.
package keywrap
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/binary"
)
// context builds unambiguous associated data from labelled parts.
func context(parts ...string) []byte {
var out []byte
for _, p := range parts {
out = binary.BigEndian.AppendUint32(out, uint32(len(p)))
out = append(out, p...)
}
return out
}
func Wrap(kek, dek []byte, ctx ...string) ([]byte, error) {
gcm, err := newGCM(kek)
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, dek, context(ctx...)), nil
}
func Unwrap(kek, blob []byte, ctx ...string) ([]byte, error) {
gcm, err := newGCM(kek)
if err != nil {
return nil, err
}
if len(blob) < gcm.NonceSize() {
return nil, cipher.ErrOpen
}
nonce, ct := blob[:gcm.NonceSize()], blob[gcm.NonceSize():]
return gcm.Open(nil, nonce, ct, context(ctx...))
}
func newGCM(kek []byte) (cipher.AEAD, error) {
block, err := aes.NewCipher(kek)
if err != nil {
return nil, err
}
return cipher.NewGCM(block)
}
Call it as Wrap(kek, dek, "records", recordID, ownerID) and unwrap with the same values taken from the row you are actually serving. Move the blob to Mallory's row and the owner or ID no longer matches, so Open returns cipher: message authentication failed.
Quick detour: why the length prefixes?
Simply joining the parts with nothing between them is a classic mistake. The parts "ab", "c" and "a", "bc" both give abc. If an attacker influences one field, such as a username, they can shift the boundary and forge a matching context.
Length prefixes make the encoding injective: every distinct list of parts gives distinct bytes. A separator character is weaker, because the data might contain it. Go's binary.BigEndian.AppendUint32 makes this a two-line job.
What to put in the context
Think of it as everything that must be true for this key to be legitimate. A reasonable starting list:
- A purpose label such as
"record-dek", so a key wrapped for backups cannot be fed to the session store. - The record ID, so rows cannot be swapped.
- The tenant or owner, so the check holds even if IDs are guessable or reused.
- The KEK version, so a blob cannot be quietly rolled back to an old key you meant to retire.
Do not put anything in there that legitimately changes. If a record can be reassigned to a new owner, you must rewrap on reassignment, and that is the point. The move becomes an explicit operation that needs the KEK, not something a database writer can do with UPDATE.
The data ciphertext needs the same treatment
Binding the wrapped key is half of it. Consider an attacker who copies the pair from Bob's row to Mallory's: both blobs, consistent with each other. If the wrap is bound to the row, the key fails to unwrap and the attack stops there. Good.
Now consider swapping only the data ciphertext between two rows that happen to share a key (a per-tenant DEK, say). Then the wrap binding does nothing, and you want the same record ID in the data encryption's associated data too. Bind at both layers. It costs one extra argument.
A test that proves it
The check that matters is the negative one. A round-trip test would have passed on the naive version.
func TestUnwrapRejectsOtherRecord(t *testing.T) {
kek := make([]byte, 32)
dek := make([]byte, 32)
rand.Read(kek)
rand.Read(dek)
blob, err := Wrap(kek, dek, "record-dek", "rec-1", "bob")
if err != nil {
t.Fatal(err)
}
if _, err := Unwrap(kek, blob, "record-dek", "rec-1", "bob"); err != nil {
t.Fatalf("legitimate unwrap failed: %v", err)
}
if _, err := Unwrap(kek, blob, "record-dek", "rec-2", "mallory"); err == nil {
t.Fatal("blob unwrapped under the wrong context")
}
}
Why not AES Key Wrap?
Dedicated key wrap algorithms (AES-KW from RFC 3394, for instance) exist and are fine for what they do. But they take no associated data, so you cannot bind a context with them directly. Many KMS products offer an "encryption context" for exactly this reason. If you roll your own wrapper with AES-GCM as above, you get the same property with the standard library alone.
One caveat: with random 96-bit GCM nonces, keep the number of wraps under a single KEK well below the usual limits, which is another reason to version the KEK and rotate it.