In the previous series we finished AES and its modes. And in the previous article revealed why it is still not secure.
We can encrypt messages.
We can decrypt messages.
We can ship.
And then reality does what reality always does:
It slaps you.
Because encryption solves one problem:
“If you don’t know the key, you can’t read the message.”
It does not solve this problem:
“If you don’t know the key, you can’t change the message.”
So we face the classic situation:
✅ we have secrecy
❌ we still don’t have trust
And now we do what every engineer does when something breaks:
we try to patch it fast.
Let’s go through the exact fixes your brain naturally generates at 2am.
Most of them fail.
Some fail spectacularly.
And each failure forces one new design constraint… until we reinvent a real solution.
Goal (simple and brutal)
We want the receiver to be able to say:
- ✅ accept
- ❌ reject
based on one small extra value.
No philosophy. No “maybe”.
Just: does this message deserve to exist?
Fix attempt #1 — “Just hash the plaintext”
Classic.
C = Enc(M)
tag = Hash(M)
send (C, tag)
Receiver decrypts C, recomputes Hash(M), compares.
Break (instant)
Hashes have no secrets.
Attacker changes message → attacker recomputes hash → sends new pair.
✅ receiver accepts
✅ attacker wins
✅ we learn nothing except pain
Takeaway
If the attacker can compute your tag, it’s not authentication.
It’s a checksum.
So the tag must involve a secret.
Fix attempt #1.5 — “Put the hash inside the encrypted message”
Okay, fine.
Let’s hide the hash.
payload = M || Hash(M)
C = Enc(payload)
send C
Receiver decrypts, extracts M and the embedded hash, recomputes, compares.
This feels smart.
It’s not.
Break (modes bite you)
In malleable modes (CTR / OFB / CFB), the attacker can flip bits in ciphertext to flip predictable bits in plaintext.
So they flip bits in the message part…
and flip corresponding bits in the embedded hash part.
They don’t need to know the key.
They don’t need to know the hash function.
They don’t need to understand anything.
They just move bits.
Receiver decrypts:
M' || Hash(M')
Hash matches. Receiver accepts.
0_o
Takeaway
Hiding a checker does not make it a check.
If your “verification data” lives inside the thing being protected, it can be modified together with it.
We need the tag to be outside the encrypted payload and unforgeable.
Fix attempt #2 — “Encrypt the hash separately”
Next idea:
C = Enc(M)
tag = Enc(Hash(M))
send (C, tag)
Now the attacker can’t see the hash, can’t recompute it.
So… done?
Not even close.
Break (you verified… what exactly?)
Now you have two encrypted blobs.
And the receiver has to answer a painful question:
What exactly are we proving by comparing these?
- Do we verify the plaintext?
- The ciphertext?
- The padding?
- The length?
- The context (endpoint / protocol version / message type)?
Nothing is bound. Everything is implied.
And implied security is not security.
Also: even if you try to “compare decrypted hash to recomputed hash”, you’re back to the earlier issue — encryption modes are malleable and protocol context is not bound.
Takeaway
Encrypting a checker is not the same as verifying a message.
We need a single verification value, not two blobs that “seem related”.
Fix attempt #2.5 — “Encrypt the ciphertext and compare”
Okay. Let’s remove ambiguity.
C = Enc(M)
tag = Enc(C)
send (C, tag)
Receiver decrypts tag → gets C' → compares C' == C.
Now we have:
- a secret
- a deterministic check
- a clean yes/no
This looks decent.
And it still fails.
Break (you authenticated bytes, not meaning)
This check proves exactly one thing:
“The ciphertext blob wasn’t modified.”
It does not prove:
- that this ciphertext belongs to this endpoint
- that it’s intended for this message type
- that it’s valid in this context
- that it’s fresh
- that it’s not a replay
So the attacker doesn’t need to forge anything.
They just replay a valid (C, tag) where it causes damage.
“Transfer $10” becomes “Transfer $10 again.”
Or becomes “Approve something you approved yesterday.”
You built a very strong proof of internal consistency… and zero proof of intent.
Takeaway
Consistency is not authenticity.
Authentication must bind meaning and context, not just bytes.
Fix attempt #3 — “Use a ciphertext artifact as the tag”
Another idea people try:
“Maybe the last ciphertext block depends on everything. Let’s use it.”
C = Enc(M)
tag = last_block(C)
Break (structural)
This “tag” depends on:
- mode internals
- padding
- message length
- where the last block boundary falls
Truncation, extension, prefixes… the whole thing becomes ambiguous.
Takeaway
Artifacts are not tags.
We need a tag function that we control, end-to-end.
Fix attempt #4 — “Fine. Let’s build a tag ourselves.”
At this point it’s pretty clear that all “attach something and encrypt it” hacks are cursed.
So let’s stop patching symptoms and define what we actually need.
We need a function that takes:
- a secret key
K - a message
Mof any length
and produces:
- a fixed-size tag (something like 16 bytes)
So not:
- Block → Block (that’s what AES gives us)
- Message → Message (that’s what modes give us)
but:
Message → Block
And yes, AES still sounds useful, because it’s keyed and strong.
The only problem is… AES doesn’t speak “message”. It speaks “one block”.
So we have to build a “message-to-block machine” out of “block-to-block”.
Which means: state.
We invent a small internal value X (one AES block), and we feed the message block-by-block:
- start with some known initial state
X0 - combine the current block with the state
- run AES
- repeat
The most natural combine operation is XOR (it keeps the size).
So the first honest design becomes:
X0 = 0
for each block Bi in message:
Xi = AES(K, Xi-1 XOR Bi)
tag = Xn
This is the first time we’re no longer “encrypting a message”.
We’re doing something else:
- the output is fixed-size no matter how long the message is
- you can’t decrypt the tag back into the message
- we are folding the message into a state
That’s not encryption. That’s compression (in the cryptographic sense).
Now: does it work?
It works almost perfectly… until you remember messages are not fixed-length.
And the moment message length varies, this construction starts bleeding
Now: break it.
The break: variable-length messages
This construction is essentially “CBC-MAC”.
It works for fixed-length messages.
It breaks for variable length.
Reason: the output tag is a valid internal state, so extension/splicing tricks become possible unless you bind the message length / finalization rules.
(And yes, this is a known and very real class of failures: CBC-MAC on variable-length messages is insecure.)
Takeaway
The end of the message must be cryptographically bound.
We must make “this is the final block” unforgeable.
Fix attempt #5 — “Fix the ending (this is where real engineering starts)”
So what exactly breaks in Fix #4?
Not the chaining itself.
The break is the ending:
- messages can end on a block boundary or not
- messages can have different lengths
- the final state of one message must not become a reusable internal state for another
So we add finalization rules that make “this is the end” cryptographically real.
Here is the mental model (extended pseudocode):
Step A — Generate two subkeys (K1, K2)
We derive subkeys from AES itself:
L = AES(K,0^128)
K1 = dbl(L)
K2 = dbl(K1)
Where dbl() is “shift-left-by-1-bit, and if a carry falls off, XOR a constant (Rb) into the last byte”.
This is not random ceremony — it gives us two distinct “domains” for the last block.
Step B — Split message into blocks
B1..B(n-1),last = split_into_16B_blocks(M)
Now two cases:
Case 1 — last block is FULL (exactly 16 bytes)
M_last = last XOR K1
Case 2 — last block is PARTIAL (0..15 bytes)
Pad it first (append 0x80 then zeros), then:
last_padded = pad_7816_4(last)
M_last = last_padded XOR K2
Step C — Run the chaining as before, but finalize with M_last
X = 0^128
for i in 1..(n-1):
X = AES(K, X XOR Bi)
tag = AES(K, X XOR M_last)
That’s the “fixed ending” logic in full.
A very important clarification: there is nothing to decrypt here
At this point, it’s worth stopping for a second and being explicit.
The output of Fix attempt #5 — the tag — is not encrypted data.
It is:
- not reversible
- not meant to be decrypted
- not carrying the message inside it
It is the final internal state of a compression process.
Verification works like this:
- The receiver already has the message
M - The receiver recomputes the tag from scratch using the same algorithm and the same key
- The receiver compares the two tags
If they match → accept
If they don’t → reject
There is no “decryption step” for the tag, because authentication is not a transformation — it’s a decision.
This is a crucial mental shift:
Encryption answers: “What was the message?”
MAC answers: “Can I trust this message?”
Trying to “decrypt a MAC” is like trying to “decrypt a checksum”.
There is nothing there to recover.
If you feel uncomfortable that the tag can’t be decrypted — good. That discomfort means you’ve stopped thinking about authentication as encryption.
Name reveal: CMAC (and what we accidentally reinvented)
So what did we just build?
-
We built a compression function: message → fixed-size tag
(not zip compression — cryptographic compression: folding data into state)
We built chaining: a state that evolves block-by-block.
We built a CBC-style MAC core:
X = AES(K, X XOR Bi).-
We discovered the “variable length” landmine, and fixed it with:
- finalization
- domain separation for the last block (full vs partial)
-
subkeys
K1,K2 - padding for the partial last block
And once you assemble those pieces, the whole thing has a name:
CMAC — Cipher-based Message Authentication Code.
CMAC is what remains after you remove all the broken variants.
Python implementation (AES-CMAC)
We’ll use an existing crypto library (because we are not trying to spend 3 weeks reimplementing AES here). Here you can find the final code: Build Own CMAC
One last observation (and the bridge to Part 3)
Look at what we built.
This tag function:
- takes arbitrary-length input
- updates a small internal state block by block
- produces a fixed-size output
That is… suspiciously familiar.
It looks like the shape of a hash function.
So in the next article we’ll do the same trick again:
Instead of forcing AES to behave like a compressor,
let’s start from a primitive that is already a compressor.
And we’ll see if we can reinvent a hash-based MAC in the same “no names until the end” style.
Top comments (0)