DEV Community

Cover image for Building Own MAC — Part 2: Fixing AES (and accidentally reinventing CMAC)
Dmytro Huz
Dmytro Huz

Posted on • Originally published at dmytrohuz.com

Building Own MAC — Part 2: Fixing AES (and accidentally reinventing CMAC)

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)

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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')

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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 M of 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

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

Now two cases:

Case 1 — last block is FULL (exactly 16 bytes)

M_last = last XOR K1

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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)

Enter fullscreen mode Exit fullscreen mode

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:

  1. The receiver already has the message M
  2. The receiver recomputes the tag from scratch using the same algorithm and the same key
  3. 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)