Articles / Cryptography

  • go
  • cryptography
  • aes-gcm
  • aead
  • associated-data
  • security

Your AES-GCM Associated Data Is Only Checked If You Bind It

You moved a ciphertext from one database row to another, and it decrypted without complaint. That should be impossible if you passed associated data (AAD) naming the row. It isn't impossible, because AES-GCM checks exactly the bytes you hand it and nothing else.

The cipher is doing its job. The bug is almost always in what you passed as AAD, or where it came from. Here are the four ways I see it go wrong in Go, and a test that catches all of them.

What AAD actually does

AAD is data that is authenticated but not encrypted. It feeds into the GCM tag, so Open fails if the AAD differs from what Seal saw. The AAD is never stored in the ciphertext; you must supply the same bytes again at decryption time, from somewhere the attacker does not control.

Here is a sound pair of helpers to measure the mistakes against:

package aeadbox

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

func Seal(aead cipher.AEAD, plaintext, aad []byte) ([]byte, error) {
	nonce := make([]byte, aead.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, err
	}
	// nonce is the dst prefix, so the blob is nonce || ciphertext || tag
	return aead.Seal(nonce, nonce, plaintext, aad), nil
}

func Open(aead cipher.AEAD, blob, aad []byte) ([]byte, error) {
	ns := aead.NonceSize()
	if len(blob) < ns+aead.Overhead() {
		return nil, errors.New("ciphertext too short")
	}
	return aead.Open(nil, blob[:ns], blob[ns:], aad)
}

Mistake 1: AAD in the dst slot

The signature is Seal(dst, nonce, plaintext, additionalData). All the byte slices are []byte, so swapping them compiles happily.

// wrong: header becomes dst, AAD is nil
out := aead.Seal(header, nonce, plaintext, nil)

The result is header || ciphertext, and nothing is bound to anything. Open it with the matching mistake and it round-trips, so every test you wrote passes. The tell is that the output has your header glued to the front.

Mistake 2: nil on both sides

A lot of code starts with nil for AAD "to get it working" and never comes back. Passing nil to both Seal and Open is valid and silent. Grep for it; a literal nil in that last argument deserves a comment saying why.

Mistake 3: taking the AAD from the thing you are verifying

This one is subtle. Suppose each stored record carries its own metadata:

type Record struct {
	OwnerID string
	Blob    []byte
}

// wrong: OwnerID comes from the same row an attacker can edit
pt, err := Open(aead, rec.Blob, []byte(rec.OwnerID))

If someone can copy a blob into another row, they can copy the owner field with it, and the tag still verifies. The AAD must come from the context of the request: the authenticated session's user ID, the row key you queried by, the tenant from the route. AAD copied from the stored record proves only that the record agrees with itself.

Mistake 4: ambiguous concatenation

Binding several fields by joining them is fine until the boundaries move. With AAD built as userID + tableName, the pair ("ab", "c") and ("a", "bc") produce identical bytes.

Use a separator the fields cannot contain, or length-prefix each one:

func aad(fields ...string) []byte {
	var b []byte
	for _, f := range fields {
		b = binary.BigEndian.AppendUint32(b, uint32(len(f)))
		b = append(b, f...)
	}
	return b
}

Remember to import encoding/binary. Include a purpose string such as "profile-v1" as the first field too, so a blob made for one feature cannot be replayed into another.

Quick detour: why the tag does not complain

Hang on, why does a blob moved between rows verify at all? Because GCM has no idea what a row is. The tag is a function of key, nonce, AAD and ciphertext, and when you moved the blob unchanged, all four still agree. The check you wanted was never part of the maths unless you made it so.

A test that fails when you get it wrong

Do not test only the happy path. Test that a ciphertext sealed for one context is refused in another:

func TestAADIsBound(t *testing.T) {
	block, _ := aes.NewCipher(make([]byte, 32))
	aead, _ := cipher.NewGCM(block)

	blob, err := Seal(aead, []byte("secret"), []byte("user:1"))
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Open(aead, blob, []byte("user:2")); err == nil {
		t.Fatal("blob opened under the wrong AAD")
	}
	if _, err := Open(aead, blob, nil); err == nil {
		t.Fatal("blob opened with no AAD")
	}
}

The all-zero key is for the test only. Mistakes 1 and 2 fail the second assertion; mistake 3 needs a test at the call site that feeds a row from the wrong owner through your real read path.

If you only have time for one habit, make it this: every call to Seal and Open takes AAD derived from the request, and a reviewer can see where it came from.