We've reached the lesson that pays off the course's debt. In 02-02 we left an uncomfortable hole: CBC and CTR encrypt just fine, but an attacker can modify the ciphertext without the key and without anyone noticing — the bit-flipping in the last exercise turned a balance of 10 into 90 with surgical precision. Encrypting gives confidentiality, not integrity. The professional answer to this problem has a name: AEAD, Authenticated Encryption with Associated Data. It combines confidentiality, integrity, and authenticity in a single operation that's impossible to use halfway. In this lesson you'll get to know AES-GCM and ChaCha20-Poly1305, understand what the tag and the associated data (AAD) are, see Fernet as a high-level recipe, and — the moment we've been building toward since module 1 — build the medinube.crypto module that finally encrypts MediNube's records at rest, with the versioned v1 format promised in 01-04.

Contents

  1. The problem: encrypting doesn't prevent tampering
  2. What AEAD adds and how (the tag)
  3. AES-GCM with AESGCM
  4. ChaCha20-Poly1305 with ChaCha20Poly1305
  5. Associated data (AAD): authenticating without encrypting
  6. Fernet: the high-level recipe
  7. Central case: encrypting MediNube's records (medinube.crypto, format v1)

The problem: encrypting doesn't prevent tampering

An encryption scheme is called malleable when an attacker can transform the ciphertext to produce a predictable change in the plaintext, without knowing the key. In 02-02 we saw this in CTR: since ciphertext = message XOR keystream, flipping one ciphertext bit flips exactly that bit of the message. CBC is malleable in a different way (manipulating one ciphertext block corrupts that block but alters the next one in a controlled fashion). The conceptual pattern of the attack, on a field of a MediNube record:

import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

key, nonce = os.urandom(32), os.urandom(16)
medical_record = b"ALLERGY: penicillin  TREATMENT: active"

c = Cipher(algorithms.AES(key), modes.CTR(nonce)).encryptor()
ciphertext = bytearray(c.update(medical_record) + c.finalize())

# The attacker does NOT know the key, but does know the template (Kerckhoffs).
# They know the TREATMENT field ends in "active".
# They flip "active" to "ceased" by toggling the exact bytes:
pos = medical_record.index(b"active")
for i, (a, b) in enumerate(zip(b"active", b"ceased")):
    ciphertext[pos + i] ^= a ^ b

d = Cipher(algorithms.AES(key), modes.CTR(nonce)).decryptor()
print(d.update(bytes(ciphertext)) + d.finalize())
# b'ALLERGY: penicillin  TREATMENT: ceased'

The attacker has altered a clinical record without ever decrypting it. In a medical system, being able to change "TREATMENT: active" to "ceased" — or worse, erase an allergy — is as serious as being able to read it. Encrypting without authenticating is half a protection, and the missing half can cost lives. We need decryption to fail loudly if even a single bit has changed.

What AEAD adds and how (the tag)

An AEAD scheme guarantees three things at once:

  • Confidentiality: no one without the key can read the message (like CTR/CBC).
  • Integrity: any modification of the ciphertext is detected.
  • Authenticity: only whoever holds the key could have produced that ciphertext.

How? When encrypting, the algorithm also computes an authentication tag: a short value — typically 16 bytes — that's a sort of cryptographic "seal" over the ciphertext and the key. When decrypting, the algorithm recomputes the tag and compares it: if it doesn't match (because someone touched a bit), the operation raises an exception and returns nothing. There's no "decrypting halfway": either the data is intact and authentic, or it's an error.

flowchart LR
    subgraph Encrypt
        M[message] --> ENC[AEAD encrypt]
        K1[key] --> ENC
        N1[nonce] --> ENC
        ENC --> CT[ciphertext + tag]
    end
    subgraph Decrypt
        CT --> DEC[AEAD decrypt]
        K2[key] --> DEC
        N2[nonce] --> DEC
        DEC -->|tag OK| OUT[message]
        DEC -->|tag bad| ERR[⚠ exception: tampered data]
    end

The tag comparison is, of course, done in constant time (remember compare_digest, 01-04): the library handles it for you. This is AEAD's great value and the reason it's today's de facto standard: it's very hard to misuse. There's no need to remember to add integrity separately or to carefully validate padding — it's all built in. The two AEAD constructions you'll use are the "superpowered" versions of this module's algorithms:

  • AES-GCM = AES in CTR mode (confidentiality) + an authenticator called GHASH (integrity).
  • ChaCha20-Poly1305 = ChaCha20 (confidentiality) + the Poly1305 authenticator (integrity).

AES-GCM with AESGCM

GCM (Galois/Counter Mode) is the world's most widely used AEAD mode: it's the one that dominates TLS. In pyca/cryptography it lives in the hazmat layer, but with a very convenient high-level API, the AESGCM class.

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

key = AESGCM.generate_key(bit_length=256)   # 32 bytes from the CSPRNG; our standard
aesgcm = AESGCM(key)

nonce = os.urandom(12)                       # GCM uses a 12-byte (96-bit) nonce
message = "ALLERGY: penicillin".encode("utf-8")

# encrypt(nonce, data, aad). The tag is INCLUDED at the end of the returned ciphertext.
ciphertext = aesgcm.encrypt(nonce, message, None)   # None = no associated data (for now)

# decrypt validates the tag; if anything changed, it raises InvalidTag.
recovered = aesgcm.decrypt(nonce, ciphertext, None)
print(recovered.decode("utf-8"))                # ALLERGY: penicillin

# Integrity test: we tamper with one byte of the ciphertext.
tampered = bytearray(ciphertext)
tampered[0] ^= 1
try:
    aesgcm.decrypt(nonce, bytes(tampered), None)
except Exception as e:
    print(type(e).__name__)                      # InvalidTag  ✅ detected

Crucial details:

  • 12-byte nonce. That's the canonical, recommended size for GCM (not the 16 used by CTR/CBC). And 02-02's golden rule still stands: never reuse the key+nonce pair. In GCM, reuse is even more serious than in CTR, because besides leaking the messages, it lets the internal authentication key be recovered and tags forged. Generate the nonce with os.urandom(12) on every encryption.
  • The tag is included. encrypt returns ciphertext || tag in a single bytes value; decrypt expects that same format. You don't have to manage the tag separately.
  • A usage limit for the same nonce/key. With random 12-byte nonces, GCM has a practical limit on messages per key (on the order of 2^32 before nonce collision probability stops being negligible). Very generous for MediNube, but that's exactly why module 6 covers key rotation. Keep this as an existing limit, not an infinite one.
  • AESGCM.generate_key(bit_length=256) is a convenient shortcut equivalent to os.urandom(32).

ChaCha20-Poly1305 with ChaCha20Poly1305

The stream-cipher alternative has an identical API, which makes switching between the two trivial (crypto-agility, 01-04):

import os
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305

key = ChaCha20Poly1305.generate_key()        # 32 bytes (ChaCha20 is always 256 bits)
chacha = ChaCha20Poly1305(key)

nonce = os.urandom(12)                          # also 12 bytes
message = "ALLERGY: penicillin".encode("utf-8")

ciphertext = chacha.encrypt(nonce, message, None)
recovered = chacha.decrypt(nonce, ciphertext, None)
print(recovered.decode("utf-8"))               # ALLERGY: penicillin

Same (nonce, data, aad) signature, same 12-byte nonce, same included tag, same rule against reusing the nonce. The choice between AESGCM and ChaCha20Poly1305 is the one from 02-01: AES-GCM on servers with AES-NI (MediNube's case), ChaCha20-Poly1305 on mobile/embedded. That's why medinube.crypto will centralize the choice: switching from one to the other will mean touching a single place.

Associated data (AAD): authenticating without encrypting

The "AD" in AEAD stands for Associated Data: information you want to authenticate but NOT encrypt. It goes in the third parameter of encrypt/decrypt. The tag is computed over it too, so if it changes, decrypt fails — but it travels in the clear.

When is this useful? When a piece of data must be visible (because the system needs it to route, version, or index before decrypting) but must not be forgeable. Typical MediNube examples:

  • The patient identifier and the format version: they must be readable without decrypting (to know who the record belongs to and which scheme to use to decrypt it), but they must not be alterable. If an attacker tries to "relabel" Ana Pérez's encrypted record as belonging to someone else, changing the AAD will make decrypt fail.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

aesgcm = AESGCM(AESGCM.generate_key(bit_length=256))
nonce = os.urandom(12)

medical_record = b"ALLERGY: penicillin"
aad = b"paciente=ana.perez;formato=v1"     # authenticated, travels IN THE CLEAR

ciphertext = aesgcm.encrypt(nonce, medical_record, aad)

# Decrypting with the SAME aad works:
print(aesgcm.decrypt(nonce, ciphertext, aad))            # b'ALLERGY: penicillin'

# Decrypting with a different aad (attacker relabels the patient) FAILS:
try:
    aesgcm.decrypt(nonce, ciphertext, b"paciente=otro;formato=v1")
except Exception as e:
    print(type(e).__name__)                           # InvalidTag ✅

The AAD ties the ciphertext to its context: the record is only valid for that patient and that version. It's an extremely powerful tool and we'll use it in the v1 format.

Fernet: the high-level recipe

Everything above is the hazmat layer. For many cases you don't need to go that deep: pyca/cryptography offers Fernet, a high-level recipe that makes every decision for you.

from cryptography.fernet import Fernet

key = Fernet.generate_key()        # ready-to-use key (URL-safe Base64 format)
f = Fernet(key)

token = f.encrypt(b"ALLERGY: penicillin")   # returns a self-contained Base64 token
print(f.decrypt(token))                      # b'ALLERGY: penicillin'

What Fernet does under the hood, so you know what you're signing up for:

  • It encrypts with AES-128 in CBC mode and authenticates with HMAC-SHA256 (we'll cover HMAC in module 3): it's AEAD "hand-composed by experts."
  • It generates the IV automatically, includes a timestamp (letting you expire tokens with decrypt(token, ttl=...)), and packages everything — version, timestamp, IV, ciphertext, tag — into a single Base64 token you can store as text.
  • It handles the format and key rotation (MultiFernet).
AESGCM / ChaCha20Poly1305 (hazmat) Fernet (high-level)
Level AEAD primitive Complete recipe
Control Total (nonce, AAD, algorithm, format) None: the library decides
Algorithm AES-256-GCM / ChaCha20-Poly1305 AES-128-CBC + HMAC-SHA256
Output format Raw bytes (you design the envelope) Self-contained Base64 token
AAD Yes No
When to use it You need AAD, your own format, AES-256, or performance General "just encrypt this" case, tokens

When Fernet is enough: when you want to "encrypt this securely" without designing a format, you don't need AAD, and AES-128 works for you. For MediNube, however, we want AES-256 (our standard), AAD (to tie the record to its patient), and our own versioned format (v1, crypto-agility): that's why we'll build on top of AESGCM. Professional rule: use Fernet unless you have a concrete reason to drop down to hazmat — and here we have one.

Central case: encrypting MediNube's records (medinube.crypto, format v1)

The moment has arrived. Since module 1 we've been carrying the debt that patient records like Ana Pérez's remain unencrypted on disk. Let's pay it off in the centralized medinube.crypto module promised in 01-04, with format v1 = AES-256-GCM.

Design of the encrypted record's format (what's stored on disk), a self-contained bytes value:

┌─────────┬───────────────┬──────────────────────────┐
│ version │  nonce        │  ciphertext + tag         │
│ 1 byte  │  12 bytes     │  (rest)                  │
└─────────┴───────────────┴──────────────────────────┘
   0x01      os.urandom(12)   AESGCM.encrypt(...)
  • The version byte at the start makes the format crypto-agile: tomorrow v2 could be ChaCha20-Poly1305 or AES with different key management, and the code will know from the first byte how to decrypt each legacy record (01-04).
  • The nonce travels alongside the ciphertext (it's public; its only obligation is not to repeat) — that's how we solve 02-02's "you have to store the IV/nonce" requirement.
  • As AAD we put the patient ID and the version: they tie the record to its owner and its scheme, authenticated even though not encrypted.
# medinube/crypto.py  — MediNube's centralized cryptographic module.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

VERSION_V1 = 0x01                      # 1 byte: identifies the AES-256-GCM format

class TamperedMedicalRecord(Exception):
    """The record was altered, corrupted, or decrypted with the wrong key/patient."""

def encrypt_medical_record_v1(key: bytes, patient_id: str, plaintext: bytes) -> bytes:
    """Encrypts a medical record with AES-256-GCM. Returns: version || nonce || (ciphertext+tag)."""
    aesgcm = AESGCM(key)                         # key = 32 bytes (where it comes from: lesson 02-04)
    nonce = os.urandom(12)                       # nonce UNIQUE per record (golden rule from 02-02)
    aad = f"paciente={patient_id};formato=v1".encode("utf-8")
    ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
    return bytes([VERSION_V1]) + nonce + ciphertext

def decrypt_medical_record(key: bytes, patient_id: str, record: bytes) -> bytes:
    """Decrypts a record. Raises TamperedMedicalRecord if the tag doesn't validate."""
    version = record[0]
    if version != VERSION_V1:
        raise ValueError(f"Unknown format version: {version}")
    nonce = record[1:13]                         # 12 bytes after the version byte
    ciphertext = record[13:]                     # ciphertext + tag
    aad = f"paciente={patient_id};formato=v1".encode("utf-8")
    aesgcm = AESGCM(key)
    try:
        return aesgcm.decrypt(nonce, ciphertext, aad)
    except Exception:                            # cryptography raises InvalidTag
        raise TamperedMedicalRecord(
            "The medical record was tampered with, or the key/patient don't match."
        )

Full usage, closing the loop with Ana Pérez:

import os
from medinube.crypto import encrypt_medical_record_v1, decrypt_medical_record, TamperedMedicalRecord

key = os.urandom(32)   # provisional master key; its real origin is lesson 02-04
medical_record = "Patient Ana Perez. ALLERGY: penicillin. Blood type O+.".encode("utf-8")

record = encrypt_medical_record_v1(key, "ana.perez", medical_record)
# 'record' is what gets stored on disk: unreadable and tamper-proof.
print(record[:1].hex(), "->", "version")       # 01 -> version

# Legitimate read:
print(decrypt_medical_record(key, "ana.perez", record).decode("utf-8"))
# Patient Ana Perez. ALLERGY: penicillin. Blood type O+.

# Attack 1: tamper with the ciphertext (02-02's bit-flipping, now blocked).
tampered = bytearray(record)
tampered[20] ^= 1
try:
    decrypt_medical_record(key, "ana.perez", bytes(tampered))
except TamperedMedicalRecord as e:
    print("BLOCKED:", e)

# Attack 2: relabel the record as belonging to another patient (AAD mismatch).
try:
    decrypt_medical_record(key, "other.patient", record)
except TamperedMedicalRecord as e:
    print("BLOCKED:", e)

Both attacks that succeeded in 02-02 now fail loudly. With this, the course's central need is finally covered: MediNube's records are stored encrypted with AES-256-GCM, with integrity, authenticity, and tied to their patient, in a versioned format ready to evolve.

Two loose ends remain, and they're deliberate:

  • Where does the 32-byte key come from? In these examples we generated it with os.urandom(32), but in a real system the key must come from somewhere stable: an administrator's passphrase, a master key from which subkeys are derived... That's exactly the topic of the next lesson, 02-04 (KDF).
  • How is that key stored, protected, and rotated in production? (secrets managers, HSMs, rotation). That's material for module 6; here we just flag it.

As always: fictional data. A real deployment with health data requires, on top of this cryptographic foundation, review by security and compliance professionals (GDPR).

Common Mistakes and Tips

  • Encrypting without authenticating in 2026. If you use raw CBC or CTR for data others can touch, you have a bug. Default: AEAD.
  • Reusing the nonce in GCM. Even more serious than in CTR: it compromises the authentication key and enables forgery. Random 12-byte nonce per operation, no exceptions.
  • Ignoring decrypt's exception. InvalidTag (or your TamperedMedicalRecord) isn't an annoyance, it's the protection working. Never catch it just to "carry on with the data anyway": if it fires, the data is invalid, full stop.
  • Putting secrets in the AAD. The AAD isn't encrypted, only authenticated. It travels in the clear. It's perfect for the patient ID and the version; never for sensitive data.
  • Forgetting to store the nonce. It must be stored with the ciphertext. That's why we included it right in the v1 record itself; don't leave it "for later."
  • Dropping to hazmat without need. If Fernet works for you (no AAD, AES-128 acceptable), use it. We drop to AESGCM because MediNube needs AES-256, AAD, and a versioned format — concrete reasons, not just because.

Exercises

  1. The safety net. Using medinube.crypto, encrypt a medical record and then, on the resulting record, try three manipulations: (a) flip a byte of the ciphertext, (b) change the patient_id when decrypting, (c) change the version byte to 0x02. Classify which exception each one raises and why.

  2. AAD travels in the clear. Demonstrate that the AAD isn't encrypted: encrypt a medical record with aad=b"paciente=ana.perez;formato=v1" and check that the substring b"ana.perez" does not appear in the ciphertext bytes, but that the record is still tied to that patient (decrypting with a different ID fails). What property of AEAD does this illustrate?

  3. Fernet vs v1. Encrypt the same medical record with Fernet and with encrypt_medical_record_v1. List three concrete differences between the two results and justify why MediNube chose to build v1 on top of AESGCM instead of using Fernet.

Solutions

  1. (a) Flipping a byte of the ciphertext → TamperedMedicalRecord (internally InvalidTag): the recomputed tag doesn't match the stored one. (b) Changing the patient_idTamperedMedicalRecord as well: the AAD is part of the tag computation, so a different AAD invalidates verification. (c) Changing the version to 0x02ValueError("Unknown format version: 2"): our decrypt_medical_record rejects the record before even attempting to decrypt, because it doesn't know how to interpret that format. All three protect the data, but via different mechanisms: (a) and (b) through the AEAD tag, (c) through the crypto-agile format's version check.

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
aesgcm = AESGCM(os.urandom(32)); nonce = os.urandom(12)
aad = b"paciente=ana.perez;formato=v1"
ciphertext = aesgcm.encrypt(nonce, b"ALLERGY: penicillin", aad)
print(b"ana.perez" in ciphertext)   # False: the AAD is NOT embedded inside the ciphertext

The ID doesn't appear in the ciphertext because the AAD isn't encrypted or appended: it only participates in the tag computation. Even so, decrypting with a different ID fails (as you saw in the example). This illustrates the essence of AEAD: you can authenticate a piece of data without encrypting it — integrity and confidentiality are independent properties that AEAD combines at will.

  1. Differences: (i) algorithm — Fernet uses AES-128-CBC+HMAC; v1 uses AES-256-GCM (our 256-bit standard). (ii) format — Fernet returns a self-contained Base64 token we don't control; v1 is binary bytes with our own version byte that we govern for crypto-agility. (iii) AAD — Fernet doesn't support associated data; v1 ties the record to the patient via AAD. MediNube chose v1 because it needs those three specific things: AES-256, control over the versioned format (for future migrations), and the authenticated link to the patient. For a case without those requirements, Fernet would have been the correct, simpler choice.

Conclusion

You've closed the hole we'd been carrying since 02-02 and paid off the course's central debt. Authenticated encryption (AEAD) unites confidentiality, integrity, and authenticity in one operation that's hard to misuse: a tag that gets recomputed on decryption and makes the operation fail at the slightest single-bit tampering. You've mastered the two standard constructions — AES-GCM (AESGCM) and ChaCha20-Poly1305 (ChaCha20Poly1305), with identical APIs and an unrepeatable 12-byte nonce —, you know how to use AAD to authenticate cleartext data like the patient ID, and you know Fernet as the high-level recipe for when you don't need anything more. And, above all, you've built medinube.crypto: Ana Pérez's records are now stored encrypted with AES-256-GCM in the versioned v1 format, resisting both reading and bit-flipping and relabeling attacks. Only one question remains, which we've deferred in every example with a provisional os.urandom(32): where does that master key come from? An administrator's password isn't 32 bytes of pure entropy, and you can't feed a passphrase directly in as a key. The answer — turning passwords into keys and deriving subkeys from a master key — is key derivation, and it closes out the module in the next lesson, 02-04: Key Derivation (KDF). See you there.

© Copyright 2026. All rights reserved