Encrypt-then-MAC
How I learned AES encryption does not tamper-proof data
Implementing security is not trivial. I want to store as little information about my users as possible and leave that responsibility to a trusted source.
My latest hobby project suri.io led me down the road of using OAuth 2.0 with OpenID Connect for authentication. Once the user is authenticated I get an `iss` (issuer accounts.google.com) and a `sub` (user ID) which is universal across Google products. This is all the information I need to identify a user and perform authorization against my website’s data.
Once I have the user’s credentials I need to create a session. The standard approach is to use a session database and store a session key in the browser’s cookie. Each request sends the session key and the server looks up the user ID. This requires yet another database and isn’t something I wanted to pursue.
This is when I learned about client sessions. Mozilla created node-client-sessions. This stores all of the session data in a cookie. No database needed! It’s even more beneficial when you’re running a multi-data center site since you don’t need site-affinity or replication.
The tricky part is preventing the user from reading or tampering with the session cookie. This is prevented by encrypting the session information using AES-CBC-256 and authenticating the IV and ciphertext with HMAC-SHA-256. The AES encryption ensures the user can not read the contents of the cookie and HMAC ensures the user does not tamper with the ciphertext or IV.
A cookie is stored in the browser with this format.
base64url(iv).base64url(ciphertext).createdAt.duration.base64url(hmac)
Here’s a real cookie created using node-client-sessions.
jaor2QA-4g_cvz-trTdCuw.HIrzVPcJn0PXRbuEHcRKUu5jGd1mwwj3_CQUyxbZxaB_nEEGZLpoNYNtU30o08Tci2eS5sakxuc5_e_hHQ-47CLlrcaNUExF0JcI85QogSLuojFzY2IZR284hr-Zqejy59DLfbd_q4Gd-54e-hkdjrxSaOVkXX9KAsbeWa7ZEanRIU_UzysE6lXwWktWFNkVRr66OCP4TdoSlateZtLo5z9YZ6JQC9X29kFWKLxwweSGMrX0xddvd6kvk0gMsURrcmLP_MpYKbztkUHkzfFZZLu5pfGXGYNAhzdLCQZc3atGOQ-530dE-J5iQYFT8HJnw-041sHGqQzRHIRSxvAISqO6M4F3d7Ohi5R1_HXEgz8.1402762170943.86400000.VzlwgwyVe10fJWYH6JNrRYBsiFwogaAMqqhEzrsa5yQ
This is all good, but I didn’t understand why the HMAC was needed. AES-256-CBC is still considered to be very secure and node-client-sessions doesn’t have a good explanation why the cookie needed to be signed with an HMAC.
AES and Block Cipher Overview
AES is a type of block cipher. It splits plaintext into 128 bit blocks and uses a cipher to encrypt each block.
The simplest AES mode of operation is ECB (electronic codebook). This encrypts each 128 bit block independently. The ciphertext from the first block is not used to encrypt the second block.
This leads to a pretty obvious problem. Check out this image of Tux unencrypted, then encrypted using AES-ECB. The patterns in the image are still clearly visible! This is because every 128 bit block of white background encrypted to the same 128 bit grey background. Every block of black encrypted to the same dark-grey color. The big problem with AES-ECB is that it doesn’t hide patterns.
This is where AES-CBC (cipher-block chaining) and IVs (initialization vectors) come in. Every block of plaintext is first XORed with another value before being encrypted. The first block is XORed with the IV. This is a random number that gets generated every time something is encrypted. Each following block of plaintext is XORed with the previous block’s ciphertext before being encrypted. Using the XORs and chaining the blocks together removes all of the patterns found in ECB.
In order to decrypt the message you need both the ciphertext and the IV. Both of these are typically sent in the clear without additional encryption.
This means either the IV or ciphertext can be manipulated before being decrypted. You would think changing either of these would cause all of the data to be completely unreadable but that’s not the case. Changing a block of ciphertext only affects the current and next block of plaintext. Also, changing the IV only affects the first block of plaintext.
You can see why here.
Each block of ciphertext is decrypted before being XORed with the previous block’s ciphertext. This is why changing the ciphertext can only affect the current and next block. The first block is a special case since it’s only dependent on the IV.
Encryption vs. Authentication
Lets go back and look at two requirements of session cookies.
- The server must encrypt the data so the user can’t read it in plaintext.
- The server must authenticate that the cookie has not been tampered with or forged.
Suppose this is our cookie plaintext.
{"sub":"012345","iss":"accounts.google.com"}
with this AES 256 key (hex)
a891f95cc50bd872e8fcd96cf5030535e273c5210570b3dcfa7946873d167c57
and IV (hex)
3bbdce68b2736ed96972d56865ad82a2
AES-256-CBC will output this ciphertext (hex)
0ad2a6fd68870697919afad773ca90f1f5798587cb3ae4a75c784e844c69eba1cdb0c19444da98341de4f54fcadaca2b
Go run the encryption yourself here https://jswebcrypto.azurewebsites.net/demo.html#/aes
Typically encryption will prevent the user from knowing the contents of the data, but suppose the user finds out each cookie follows this format.
AES works on 128 bit blocks (16 bytes). Assuming ASCII or 1-byte UTF-8 characters, this will divide the data into these plaintext blocks before encrypting them.
{"sub":"012345", "iss":"accounts. google.com"}
If I know my own user ID is 012345, I can forge a session cookie with any other user’s ID.
The input to the first AES block is an XOR of the IV with the first 128 bits.
3bbdce68b2736ed96972d56865ad82a2 XOR {"sub":"012345" => 409fbd1dd05154fb5943e75b5198a0a2
Decryption reverses this by doing an XOR of the IV with the decrypted input.
3bbdce68b2736ed96972d56865ad82a2 XOR 409fbd1dd05154fb5943e75b5198a0a2 => {"sub":"012345"
Here’s how we forge the IV. XOR the original IV with the original string which zeros out the plaintext, then XOR that with a forged string.
3bbdce68b2736ed96972d56865ad82a2 XOR {"sub":"012345" XOR {"sub":"987654" => 3bbdce68b2736ed9607bd06d64ac82a2
Note how this is slightly different by a few hex characters in the middle.
If we swap out the IV on the cookie for this forged IV it will decrypt to our forged user ID.
3bbdce68b2736ed9607bd06d64ac82a2 XOR 409fbd1dd05154fb5943e75b5198a0a2 => {"sub":"987654"
That’s it! We have a session cookie for user 987654! All of the other blocks will stay the same since the IV only affects the first block of plaintext during the decryption step. We’ve effectively forged a new session under a different user ID.
You can run the XOR yourself here http://erikringsmuth.github.io/xor-iv-strings/. This will give you the forged IV.
3bbdce68b2736ed9607bd06d64ac82a2
Demo
Go back and plug in this ciphertext
0ad2a6fd68870697919afad773ca90f1f5798587cb3ae4a75c784e844c69eba1cdb0c19444da98341de4f54fcadaca2b
at https://jswebcrypto.azurewebsites.net/demo.html#/aes. Then decrypt it. You’ll see the original plaintext.
Now plug in this forged IV
3bbdce68b2736ed9607bd06d64ac82a2
Then decrypt it. You’ll see the forged plaintext.
Try generating some other keys, IVs, and compute new forged strings using http://erikringsmuth.github.io/xor-iv-strings/.
Authenticated Encryption
This is why we need authenticated encryption. Using Encrypt-then-MAC ensures that the user can’t tamper with the ciphertext or IV. First we do the normal AES-256-CBC encryption. The result is an IV and ciphertext. Then we send the IV and ciphertext through HMAC-SHA-256 to generate a digest. The IV, ciphertext, and digest are all included in the session cookie. If the user tampers with the IV or ciphertext they would also have to know the HMAC key to generate a new digest. If the user changes the digest then the IV and ciphertext won’t authenticate.
There are additional measures that can be taken like constant time HMAC comparisons to prevent timing attacks that could let a user determine the HMAC key.
In short, encryption will hide your data but it doesn’t prevent tampering. That’s what you need authentication for. node-client-sessions takes this all into account and puts it together with a really nice API. If you’re running node.js you should be good to go. It’s also pretty straight forward to write your own session cookies as long as you remember to Encrypt-then-MAC.
Notes
You can also manually run the XOR JavaScript from a browser console or node.js REPL.
var iv = '3bbdce68b2736ed96972d56865ad82a2';
var originalString = '{"sub":"012345"';
var forgedString = '{"sub":"987654"';
var result = new Array(16);
for (var i = 0; i < 16; i++) {
var temp = originalString.charCodeAt(i)
^ forgedString.charCodeAt(i)
^ parseInt(iv.substr(2 * i, 2), 16);
result[i] = ("0" + temp.toString(16)).substr(-2);
}
result.join('');
This will print out a forged IV.