Cryptography · lesson 2 of 8
Beginner
2Intermediate
5Also part of Cryptography
9- + Python Security from Python
- + Java Security from Java
- + Data Security and PII: Hashing, Masking, and Tokenization from Data Engineering
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + JWT Authentication in Spring Boot from Java
- + JavaScript Security Best Practices from JavaScript
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
Symmetric Encryption with AES-GCM: Tampering Raises InvalidTag
Encrypt with AES-GCM in Python, then flip one bit, use the wrong key or the wrong associated data, and watch each raise InvalidTag instead of garbage.
By SkillByExample · Published
Symmetric encryption uses one secret key to both encrypt and decrypt. The modern default is an
authenticated mode: AES-GCM or ChaCha20-Poly1305. These don’t just hide the data, they refuse to
decrypt anything that was changed. This lesson shows that refusal, and what you get without it. Start a
container with docker run --rm -it -v "$PWD":/work -w /work python:3.12-slim bash, then run
pip install cryptography==50.0.2. The examples were run with Python 3.12.15 and cryptography 50.0.2.
Every key is generated fresh on each run.
The round trip, and the 16 extra bytes
AESGCM.encrypt takes a key, a nonce, the plaintext, and optional associated data, which is
authenticated but left readable.
# roundtrip.py
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256) # demo-only: a new key every run
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 96 bits, never reused with this key
plaintext = b"card=4111-FAKE-0000 exp=12/29"
aad = b"user_id=42" # authenticated, not encrypted
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
print("plaintext: ", len(plaintext), "bytes")
print("ciphertext:", len(ciphertext), "bytes =", len(plaintext), "+ 16-byte tag")
print("ciphertext:", ciphertext[:16].hex(), "...")
print("decrypted: ", aesgcm.decrypt(nonce, ciphertext, aad))
$ python roundtrip.py
plaintext: 29 bytes
ciphertext: 45 bytes = 29 + 16-byte tag
ciphertext: 8bb9648b52e02a9c8a965698decb1c2e ...
decrypted: b'card=4111-FAKE-0000 exp=12/29'
The ciphertext is exactly as long as the plaintext, plus 16 bytes. There’s no padding: GCM is built on counter mode, which encrypts byte for byte. The 16 extra bytes are the authentication tag, computed over the ciphertext and the associated data. Your hex will differ on every run, because the key and the nonce are random. The nonce isn’t secret, but it must never repeat under the same key. Lesson 3 shows what goes wrong when it does.
Three ways to break it, one exception
Now change one thing at a time: a single ciphertext bit, the key, and the associated data.
# tamper.py
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256)
nonce = os.urandom(12)
ct = AESGCM(key).encrypt(nonce, b"pay alice amount=100", b"user_id=42")
flipped = bytearray(ct)
flipped[0] ^= 0x01 # one bit of the first byte
attempts = {
"one bit flipped": (key, flipped, b"user_id=42"),
"wrong key": (AESGCM.generate_key(bit_length=256), ct, b"user_id=42"),
"wrong AAD": (key, ct, b"user_id=43"),
"untouched": (key, ct, b"user_id=42"),
}
for name, (k, c, aad) in attempts.items():
try:
print(f"{name:16}", AESGCM(k).decrypt(nonce, bytes(c), aad))
except InvalidTag as e:
print(f"{name:16}", type(e).__name__, "message:", repr(str(e)))
$ python tamper.py
one bit flipped InvalidTag message: ''
wrong key InvalidTag message: ''
wrong AAD InvalidTag message: ''
untouched b'pay alice amount=100'
Each failure raises InvalidTag, and none of them returns any plaintext, not even a garbled version. The
exception’s message is empty on purpose. An error that said “bad key” or “byte 0 modified” would tell an
attacker which guess was closer, so the library tells you only that verification failed. The “wrong AAD”
case is the most useful in practice: the ciphertext is untouched, but it was sealed for user_id=42, so
it won’t open as anyone else’s.
Without authentication, the edit goes through
To see why the tag matters, here’s AES in plain counter mode (CTR), with no tag, on this demo’s own data. CTR encrypts by XORing the plaintext with a keystream.
# ctr_malleable.py
# Why authentication matters: AES-CTR alone, on this demo's own data.
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key, nonce = os.urandom(32), os.urandom(16)
ctr = Cipher(algorithms.AES(key), modes.CTR(nonce))
ct = ctr.encryptor().update(b"pay alice amount=100")
edited = bytearray(ct)
edited[17] ^= ord("1") ^ ord("9") # no key needed to make this edit
print(ctr.decryptor().update(bytes(edited)))
$ python ctr_malleable.py
b'pay alice amount=900'
The edit used no key. Byte 17 of the plaintext is 1, and XORing the ciphertext with '1' XOR '9'
turns it into 9 after decryption, because each ciphertext bit lines up with one plaintext bit. Anyone who
knows or can guess the message format can make that edit, and the receiver decrypts it without complaint.
This is called malleability. CBC mode has related problems, such as padding oracles. That’s why the rule
is to use an AEAD mode (GCM or ChaCha20-Poly1305), or Fernet, and never to bolt a MAC onto a raw mode
yourself. The fix for this script is the previous section: the same edit on a GCM ciphertext raised
InvalidTag.
Storing the nonce with the ciphertext
The receiver needs the nonce to decrypt. The usual format is to put the 12-byte nonce in front of the ciphertext, because it isn’t secret.
# store.py
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256)
def seal(plaintext: bytes, aad: bytes) -> bytes:
nonce = os.urandom(12)
return nonce + AESGCM(key).encrypt(nonce, plaintext, aad)
def open_(blob: bytes, aad: bytes) -> bytes:
nonce, ct = blob[:12], blob[12:]
return AESGCM(key).decrypt(nonce, ct, aad)
a = seal(b"same secret", b"row=1")
b = seal(b"same secret", b"row=2")
print("stored sizes:", len(a), len(b))
print("identical blobs?", a == b)
print(open_(a, b"row=1"), open_(b, b"row=2"))
$ python store.py
stored sizes: 39 39
identical blobs? False
b'same secret' b'same secret'
Each blob is 12 bytes of nonce, 11 of ciphertext and 16 of tag, so 39 bytes. The same plaintext encrypted
twice gives different blobs, because each call draws a fresh nonce. That matters: if equal plaintexts gave
equal ciphertexts, anyone reading the table could see which rows share a secret. seal generates the
nonce itself, so callers can’t get it wrong. That’s the shape to copy.
Practice
- Stop a ciphertext being moved to another row. Using
seal/open_with the row ID as associated data, copy row 2’s stored blob over row 1, then try to open row 1. What happens, and why?
Solution
# swap_rows.py
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256)
def seal(plaintext: bytes, aad: bytes) -> bytes:
nonce = os.urandom(12)
return nonce + AESGCM(key).encrypt(nonce, plaintext, aad)
def open_(blob: bytes, aad: bytes) -> bytes:
return AESGCM(key).decrypt(blob[:12], blob[12:], aad)
rows = {1: seal(b"salary=50000", b"row=1"), 2: seal(b"salary=90000", b"row=2")}
rows[1] = rows[2] # copy row 2's ciphertext over row 1
try:
print(open_(rows[1], b"row=1"))
except InvalidTag:
print("row 1: InvalidTag, the swapped ciphertext is refused")
$ python swap_rows.py
row 1: InvalidTag, the swapped ciphertext is refused
The copied blob is a perfectly valid ciphertext, but it was sealed for row=2. Someone with write access
to the database but no key can’t give themselves a higher salary by moving ciphertexts around. Without the
AAD, the swap would have decrypted.
- Switch to ChaCha20-Poly1305. It’s the other standard AEAD, and it’s faster than AES on CPUs without AES hardware instructions. Encrypt and decrypt with it, then flip a bit in the tag (the last byte).
Solution
# chacha.py
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
key = ChaCha20Poly1305.generate_key()
nonce = os.urandom(12)
ct = ChaCha20Poly1305(key).encrypt(nonce, b"pay alice amount=100", None)
print("round trip:", ChaCha20Poly1305(key).decrypt(nonce, ct, None))
bad = bytearray(ct)
bad[-1] ^= 0x80 # flip a bit in the tag itself
try:
ChaCha20Poly1305(key).decrypt(nonce, bytes(bad), None)
except InvalidTag:
print("tampered: InvalidTag")
$ python chacha.py
round trip: b'pay alice amount=100'
tampered: InvalidTag
The API is the same as AESGCM: a 32-byte key, a 12-byte nonce, and None when there’s no associated
data. A corrupted tag fails just like a corrupted ciphertext.
- Use Fernet, the high-level recipe. Encrypt a message with
cryptography.fernet.Fernet, decrypt it, then change one character of the token.
Solution
# fernet_demo.py
from cryptography.fernet import Fernet, InvalidToken
key = Fernet.generate_key() # demo-only: urlsafe base64 of 32 random bytes
f = Fernet(key)
token = f.encrypt(b"pay alice amount=100")
print(token[:20], "...", len(token), "chars")
print(f.decrypt(token))
bad = bytearray(token)
bad[40] = ord("A") if bad[40] != ord("A") else ord("B") # change one character
try:
f.decrypt(bytes(bad))
except InvalidToken:
print("tampered token: InvalidToken")
$ python fernet_demo.py
b'gAAAAABqzovkYF43v9Uz' ... 120 chars
b'pay alice amount=100'
tampered token: InvalidToken
Fernet picks the IV, adds a timestamp and an HMAC, and base64-encodes the result, so there’s no nonce to
manage. Every token starts with gAAAAA: a version byte, then the start of the timestamp. Lesson 8 uses
MultiFernet to rotate Fernet keys.
- Truncate and extend the ciphertext. Try decrypting the ciphertext with the whole tag removed, with one byte removed, and with one byte appended. Predict each result first.
Solution
# truncate.py
import os
from cryptography.exceptions import InvalidTag
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
key = AESGCM.generate_key(bit_length=256)
nonce = os.urandom(12)
ct = AESGCM(key).encrypt(nonce, b"pay alice amount=100", None)
for cut in [ct[:-16], ct[:-1], ct + b"\x00"]:
try:
AESGCM(key).decrypt(nonce, cut, None)
print(len(cut), "bytes: decrypted")
except InvalidTag:
print(len(cut), "bytes: InvalidTag")
$ python truncate.py
20 bytes: InvalidTag
35 bytes: InvalidTag
37 bytes: InvalidTag
All three fail. GCM always treats the last 16 bytes as the tag, so stripping the tag doesn’t give you unauthenticated plaintext: the last 16 of the 20 remaining bytes (ciphertext bytes 4 to 19) are read as the tag, and they don’t match. Where AEAD alone isn’t enough is a stream split into many separately sealed chunks: dropping the last chunk leaves every remaining chunk valid. Streaming formats handle that by authenticating each chunk’s position and a “final chunk” flag.