Frontend Labs
⋯

Chapter 15 — AEAD: encryption and authentication together

In one sentence

AEAD gives you confidentiality and integrity in one operation, and it is what you should use for essentially all new encryption.

The problem it solves

Every mode in Chapter 14 lacked integrity. That means an attacker can modify ciphertext, and the receiver decrypts garbage — or worse, meaningful garbage (the bit-flipping attack from Chapter 12).

You could bolt on an HMAC (Chapter 11), but people got the details wrong for twenty years:

   Encrypt-and-MAC  (SSH)      MAC the PLAINTEXT, send both.
                               -> the MAC can leak plaintext info

   MAC-then-Encrypt (old TLS)  MAC the plaintext, then encrypt both.
                               -> must decrypt before verifying
                               -> padding oracle attacks

   Encrypt-then-MAC (correct)  Encrypt, then MAC the CIPHERTEXT.
                               -> verify FIRST, reject invalid data
                                  without ever decrypting it

Encrypt-then-MAC is the correct order. AEAD modes implement it for you, in a single reviewed primitive, so you cannot get it wrong.

How AEAD works

                 +-------------------------------------------+
   plaintext --> |                                            |
   key       --> |            AEAD ENCRYPT                    | --> ciphertext
   nonce     --> |                                            | --> TAG (16 bytes)
   AAD       --> |                                            |
                 +-------------------------------------------+

   AAD = "Additional Authenticated Data"
         Data that is AUTHENTICATED but NOT ENCRYPTED.
         e.g. a message header, a record number, a version field.
         Anyone can read it; nobody can change it undetected.

On decryption, the tag is verified first. If it fails, you get an error and no plaintext at all. That single property eliminates whole attack classes.

The two you should use

AES-GCM ChaCha20-Poly1305
Key 128 or 256 bits 256 bits
Nonce 96 bits (12 bytes) 96 bits
Tag 128 bits (16 bytes) 128 bits
Speed with AES-NI Very fast (GB/s) fast
Speed without AES-NI slow Very fast
Timing-safe in software hard yes, by design
Nonce reuse catastrophic — also leaks the auth key catastrophic
Max data per key/nonce ~64 GB per nonce effectively unlimited

Pick either. Use AES-256-GCM on servers, ChaCha20-Poly1305 on mobile/embedded without AES hardware. TLS negotiates this automatically.

Also worth knowing: XChaCha20-Poly1305 uses a 192-bit nonce, which is large enough that random nonces will never collide in practice. If you're encrypting many messages and are nervous about nonce management, this is the safest choice. (Available in libsodium; not in TLS.)

The nonce rule

   +---------------------------------------------------------------+
   |  NEVER use the same (key, nonce) pair twice.                    |
   |                                                                 |
   |  With GCM this is worse than with CTR: it not only reveals      |
   |  the XOR of plaintexts, it lets an attacker recover the         |
   |  authentication key and FORGE arbitrary messages.               |
   |                                                                 |
   |  Safe approaches:                                               |
   |    * random 96-bit nonce, and rotate the key every ~2^32        |
   |      messages (birthday bound)                                   |
   |    * a strict counter, if you can guarantee it never resets      |
   |    * XChaCha20-Poly1305 with random 192-bit nonces               |
   +---------------------------------------------------------------+

The nonce is not secret — send it alongside the ciphertext. It just must be unique.

The standard message format

   [ nonce (12 bytes) ][ ciphertext (n bytes) ][ tag (16 bytes) ]

   Overhead: 28 bytes per message, regardless of size.

Most libraries append the tag to the ciphertext automatically. You store/transmit the nonce with it.

Practice (35 min)

cd ~/sec-lab/scratch
python3 -m venv .venv && .venv/bin/pip install -q cryptography

.venv/bin/python - <<'PY'
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305

key   = AESGCM.generate_key(bit_length=256)
aead  = AESGCM(key)
nonce = os.urandom(12)
aad   = b"msg-version:1;user:alice"      # authenticated, NOT encrypted

pt = b"attack at dawn"
ct = aead.encrypt(nonce, pt, aad)

print("plaintext :", pt, f"({len(pt)} bytes)")
print("ciphertext:", ct.hex(), f"({len(ct)} bytes = {len(pt)} + 16 tag)")
print("decrypted :", aead.decrypt(nonce, ct, aad))

# 1. Tamper with ONE BIT of ciphertext
bad = bytearray(ct); bad[0] ^= 0x01
try:
    aead.decrypt(nonce, bytes(bad), aad)
except Exception as e:
    print("\ntampered ciphertext ->", type(e).__name__, "(no plaintext returned)")

# 2. Tamper with the AAD (which travels in the clear)
try:
    aead.decrypt(nonce, ct, b"msg-version:1;user:mallory")
except Exception as e:
    print("tampered AAD        ->", type(e).__name__)

# 3. Wrong nonce
try:
    aead.decrypt(os.urandom(12), ct, aad)
except Exception as e:
    print("wrong nonce         ->", type(e).__name__)

# 4. ChaCha20-Poly1305 -- identical interface
c = ChaCha20Poly1305(ChaCha20Poly1305.generate_key())
n = os.urandom(12)
print("\nchacha20 round trip:", c.decrypt(n, c.encrypt(n, b"hello", None), None))
PY

Expected output confirms: 14-byte plaintext → 30-byte output (the 16-byte tag), and every tampering attempt raises InvalidTag with no plaintext returned.

Break it — the AAD lesson: encrypt a message with AAD {"role":"user"} and try to change it to {"role":"admin"} on the wire. It fails. Now do the same with plain CTR mode and watch it succeed. That contrast is the whole value of AEAD.

Encrypting files with OpenSSL

cd ~/sec-lab/scratch
echo "top secret plans" > secret.txt

# Password-based (OpenSSL derives the key with PBKDF2)
openssl enc -aes-256-cbc -pbkdf2 -iter 600000 -salt \
        -in secret.txt -out secret.enc -pass pass:'correct horse battery staple'

openssl enc -d -aes-256-cbc -pbkdf2 -iter 600000 \
        -in secret.enc -pass pass:'correct horse battery staple'

Note: openssl enc does not support AEAD modes properly (there's no way to handle the tag). For real work use age, libsodium, or your language's AEAD API. openssl enc is fine for a quick file with a password, and the -pbkdf2 -iter flags are mandatory — without them OpenSSL uses a single MD5 iteration, which is trivially crackable.

age is the modern replacement and is much harder to misuse:

# brew install age   /   apt install age
age-keygen -o key.txt
age -r $(grep 'public key' key.txt | cut -d: -f2 | tr -d ' ') -o secret.age secret.txt
age -d -i key.txt secret.age

Common confusions

  • "AEAD is slower because it does two things." GCM and Poly1305 are designed for this; the overhead is small, and it's far faster than encrypt + separate HMAC.
  • "I don't need AAD." Use it for anything that travels in the clear but must not be swapped: record IDs, versions, key IDs, S3 object keys. It prevents ciphertext substitution attacks.
  • "The tag is optional if I trust the network." No. TLS terminates somewhere, storage gets tampered with, and "trusted" networks get breached.

Check yourself

  1. What two properties does AEAD provide?
  2. What is AAD, and give a concrete use for it.
  3. What is returned if the tag check fails?
  4. Why is nonce reuse worse in GCM than in CTR?
  5. Why must openssl enc be used with -pbkdf2 -iter?

Further reading

  • RFC 5116 — the AEAD interface definition. Short.
  • RFC 8439 — ChaCha20 and Poly1305. Unusually clear for an RFC, with test vectors.
  • Tool: age (github.com/FiloSottile/age) — read its design rationale; it's a masterclass in making crypto hard to misuse.
  • Article: "Cryptographic Right Answers" (Latacora) — the AEAD section.

Generated from real-life-guide.md · 8 Oct 2026 16:36