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
- The problem: encrypting doesn't prevent tampering
- What AEAD adds and how (the tag)
- AES-GCM with
AESGCM - ChaCha20-Poly1305 with
ChaCha20Poly1305 - Associated data (AAD): authenticating without encrypting
- Fernet: the high-level recipe
- Central case: encrypting MediNube's records (
medinube.crypto, formatv1)
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 ✅ detectedCrucial 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.
encryptreturnsciphertext || tagin a singlebytesvalue;decryptexpects 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 toos.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: penicillinSame (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
decryptfail.
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
v2could 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
keycome from? In these examples we generated it withos.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 yourTamperedMedicalRecord) 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
v1record itself; don't leave it "for later." - Dropping to
hazmatwithout need. If Fernet works for you (no AAD, AES-128 acceptable), use it. We drop toAESGCMbecause MediNube needs AES-256, AAD, and a versioned format — concrete reasons, not just because.
Exercises
-
The safety net. Using
medinube.crypto, encrypt a medical record and then, on the resultingrecord, try three manipulations: (a) flip a byte of the ciphertext, (b) change thepatient_idwhen decrypting, (c) change the version byte to0x02. Classify which exception each one raises and why. -
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 substringb"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? -
Fernet vs
v1. Encrypt the same medical record with Fernet and withencrypt_medical_record_v1. List three concrete differences between the two results and justify why MediNube chose to buildv1on top ofAESGCMinstead of using Fernet.
Solutions
-
(a) Flipping a byte of the ciphertext →
TamperedMedicalRecord(internallyInvalidTag): the recomputed tag doesn't match the stored one. (b) Changing thepatient_id→TamperedMedicalRecordas well: the AAD is part of the tag computation, so a different AAD invalidates verification. (c) Changing the version to0x02→ValueError("Unknown format version: 2"): ourdecrypt_medical_recordrejects 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.
- Differences: (i) algorithm — Fernet uses AES-128-CBC+HMAC;
v1uses AES-256-GCM (our 256-bit standard). (ii) format — Fernet returns a self-contained Base64 token we don't control;v1is binarybyteswith our own version byte that we govern for crypto-agility. (iii) AAD — Fernet doesn't support associated data;v1ties the record to the patient via AAD. MediNube chosev1because 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.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
