Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Applied Cryptography for Developers  Module 7 – Symmetric encryption and AEAD

Block ciphers and modes: why ECB and raw CBC fail

AES as a keyed permutation, why ECB shows patterns, why CBC and CTR let changed bytes through, and encrypt-then-MAC, run against NIST examples.

  • Intermediate
  • 20 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8 and Pyodide 314.0.7
  • By MySmartCoPilot
Free: 20 minutes

What you will learn

  • Describe AES as a keyed permutation of 16-byte blocks and say what a mode of operation adds
  • Show why ECB leaks repeated blocks and why a CBC IV must be fresh and unpredictable
  • Demonstrate with a tamper check that CBC and CTR decrypt changed ciphertext without an error
  • Add integrity with encrypt-then-MAC, using separate keys and checking the tag before decrypting

Before you start

What you’ll learn · Before you start

What you will learn

  • Describe AES as a keyed permutation of 16-byte blocks and say what a mode of operation adds
  • Show why ECB leaks repeated blocks and why a CBC IV must be fresh and unpredictable
  • Demonstrate with a tamper check that CBC and CTR decrypt changed ciphertext without an error
  • Add integrity with encrypt-then-MAC, using separate keys and checking the tag before decrypting

Before you start

On this page

Encrypt a picture of a padlock with AES in ECB mode and the padlock is still there: in this lesson’s run, 216 blocks of ciphertext held just 2 different values, one for the paper and one for the ink. AES did its job; the mode wrapped around it decides what leaks and what can be changed without anyone noticing.

This lesson takes AES for what FIPS 197 defines it to be, a keyed permutation of 16-byte blocks, and runs it on the standard’s own example. It then shows why ECB leaks, what CBC and CTR fix and what they leave open, counts how many changed messages CBC decrypts without an error, and builds encrypt-then-MAC, the check to add when an authenticated mode is not available.

AES encrypts one 16-byte block, and nothing else

Appendix B of FIPS 197 works through one example: the 16-byte block 3243f6a8…0734 under the key 2b7e1516…4f3c becomes 3925841d…0b32. That is all AES does. A block cipher: A cipher that encrypts one block of a fixed size under a key, 16 bytes for AES. Under one key it is a permutation: every block maps to exactly one ciphertext block, and the inverse cipher maps it back. takes exactly one block, 128 bits for AES, and a key of 128, 192 or 256 bits, and runs 10, 12 or 14 rounds to produce another block. Under one key it rearranges the set of all possible blocks, a permutation, so every output has exactly one input, and the inverse cipher with the same key finds it. Step through the example:

Interactive · step by step One block through AES-128 Step through one call of the block cipher on the example of FIPS 197, then back through the inverse cipher with the same key.
One block through AES-128, step 6 of 6. Showing Input block, Key, AES-128, Output block, Inverse cipher, The input again. In focus: Input block, Output block.Input block3243f6a8…0734, 16 bytesKey2b7e1516…4f3c, 128 bitsAES-12810 roundsOutput block3925841d…0b32, 16 bytesInverse cipherthe same keyThe input again3243f6a8…0734always this value for this input

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. One block in. AES takes exactly one block of 16 bytes, no more and no less. This is the input of the cipher example in FIPS 197, Appendix B.
  2. The key picks a permutation. A 128-bit key selects one way of rearranging all 2¹²⁸ possible blocks, a permutation. A key that differs in one bit selects an unrelated one.
  3. Ten rounds. AES-128 runs 10 rounds of byte substitution, row shifting, column mixing (left out of the last round) and adding a round key. All 11 round keys come from that one key.
  4. One block out. Out comes another 16-byte block, 3925841d…0b32, exactly the value the standard prints and the program reproduces.
  5. The same key undoes it. The inverse cipher with the same key turns the output back into the input. A permutation can always be undone, and only the key holder can do it.
  6. Same in, same out. Nothing else enters the call, so the same block under the same key always gives the same output. Data longer than 16 bytes needs a rule for many calls: a mode of operation.

The program runs the same block through node:crypto, which exposes the raw block function as aes-128-ecb on a single block without padding, and then asks for the things a block cipher cannot do:

AES-128 on FIPS 197's example, and what a block cipher cannot do JavaScript · block-js/aes_block.mjs
// AES-128 as a keyed permutation of 16-byte blocks. node:crypto exposes the raw block function as 'aes-128-ecb' on a
// single block without padding, which is enough to run the cipher example of FIPS 197 Appendix B. Web Crypto offers
// no such call at all.
import { createCipheriv, createDecipheriv } from 'node:crypto';

const hex = (bytes) => Buffer.from(bytes).toString('hex');
const KEY = Buffer.from('2b7e151628aed2a6abf7158809cf4f3c', 'hex'); // FIPS 197, Appendix B
const INPUT = Buffer.from('3243f6a8885a308d313198a2e0370734', 'hex');
const OUTPUT = '3925841d02dc09fbdc118597196a0b32';

/** One call of the block function: 16 bytes in, 16 bytes out, under one key. */
function aes(key, block, direction = 'encrypt') {
  const cipher = direction === 'encrypt' ? createCipheriv('aes-128-ecb', key, null) : createDecipheriv('aes-128-ecb', key, null);
  cipher.setAutoPadding(false);
  return Buffer.concat([cipher.update(block), cipher.final()]);
}
const bitsSet = (byte) => byte.toString(2).replaceAll('0', '').length;
const bitsDiffer = (a, b) => a.reduce((n, x, i) => n + bitsSet(x ^ b[i]), 0);

const out = aes(KEY, INPUT);
console.log(`AES-128(key, input) = ${hex(out)}`);
console.log(`  as FIPS 197 Appendix B: ${hex(out) === OUTPUT}`);
console.log(`  the same block and key again: ${hex(aes(KEY, INPUT))}`);
console.log(`  the inverse cipher gives the input back: ${hex(aes(KEY, out, 'decrypt')) === hex(INPUT)}`);

const otherKey = Buffer.from(KEY);
otherKey[15] ^= 0x01; // the key's last bit
console.log(`one key bit changed: ${bitsDiffer(aes(otherKey, INPUT), out)} of 128 output bits differ`);

try {
  const cipher = createCipheriv('aes-128-ecb', KEY, null);
  cipher.setAutoPadding(false);
  cipher.update(Buffer.alloc(17));
  cipher.final();
} catch (error) {
  console.log(`17 bytes, no mode, no padding: ${error.code}`);
}
try {
  await crypto.subtle.importKey('raw', KEY, 'AES-ECB', false, ['encrypt']);
} catch (error) {
  console.log(`Web Crypto asked for 'AES-ECB': ${error.name}: ${error.message}`);
}

Output

AES-128(key, input) = 3925841d02dc09fbdc118597196a0b32
  as FIPS 197 Appendix B: true
  the same block and key again: 3925841d02dc09fbdc118597196a0b32
  the inverse cipher gives the input back: true
one key bit changed: 58 of 128 output bits differ
17 bytes, no mode, no padding: ERR_OSSL_WRONG_FINAL_BLOCK_LENGTH
Web Crypto asked for 'AES-ECB': NotSupportedError: Unrecognized algorithm name

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node aes_block.mjs

The output matches the standard, the inverse gives the input back, and a key that differs in its last bit moved 58 of the 128 output bits: each key is a different permutation. Two limits show up as well. Seventeen bytes are refused with ERR_OSSL_WRONG_FINAL_BLOCK_LENGTH, because the cipher handles whole blocks only. And the same block under the same key always gave the same output, because nothing else goes into the call. A mode of operation: The rule that applies a block cipher to data of any length: how the data is cut into blocks, what is mixed into each call, such as an IV or a counter, and what is stored. ECB, CBC, CTR and GCM are modes. fixes both: it decides how data of any length is cut into blocks, what is mixed into each call, and what extra value, an IV or a counter, is stored with the result. Web Crypto offers AES only through modes, and asking it for AES-ECB fails with NotSupportedError.

Checkpoint

Check: AES as a keyed permutation

  1. Question 1 of 2 The block 3243f6a8…0734 is encrypted twice with AES-128 under the same key, with no mode around it. How do the two outputs compare?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: They are identical, 3925841d…0b32 both times

    Under one key AES is a fixed permutation of 16-byte blocks: one input, one output, every time. That determinism is why a mode has to add something that changes from call to call.

  2. Question 2 of 2 Your message is 17 bytes long. What does a mode of operation decide that AES alone cannot?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: How the data is cut into blocks, what is mixed into each call, and which IV or counter is stored with the result

    A block cipher encrypts one block. ECB, CBC, CTR and GCM are rules for applying it to longer data, and the good ones also make equal messages encrypt differently.

ECB encrypts equal blocks to equal ciphertext

The simplest mode is ECB (electronic codebook): The mode that encrypts every block on its own under the key, so equal plaintext blocks give equal ciphertext blocks and patterns show through. ASVS 5.0 rules it out., electronic codebook: cut the data into 16-byte blocks and encrypt each one on its own. NIST SP 800-38A says what follows in one sentence: under a given key, a plaintext block “always gets encrypted to the same ciphertext block”. The program draws a padlock in which every cell is one block, 16 bytes of 0x00 for the paper and 16 bytes of 0xff for the ink, encrypts it with ECB, CBC and CTR under one key, and prints each ciphertext block as its first hex digit, which is all anyone holding the ciphertext needs:

A padlock of 216 blocks, encrypted with ECB, CBC and CTR JavaScript · ecb-js/ecb_picture.mjs
// A picture of a padlock, 24 cells wide and 9 high, where every cell is one 16-byte block: 16 bytes of 0x00 for the
// paper, 16 bytes of 0xff for the ink. It is encrypted three ways under one key, and each ciphertext block is drawn as
// its first hex digit, which is all an observer of the ciphertext needs. The key, IV and counter are fixed lab values
// so that the run repeats; real code draws a random key and a fresh IV or nonce for every message.
import { createCipheriv } from 'node:crypto';

const PICTURE = [
  '........................',
  '.........######.........',
  '........##....##........',
  '........##....##........',
  '......############......',
  '......#####..#####......',
  '......#####..#####......',
  '......############......',
  '........................',
];
const WIDTH = PICTURE[0].length;
const plaintext = Buffer.concat(PICTURE.join('').split('').map((cell) => Buffer.alloc(16, cell === '#' ? 0xff : 0x00)));

const KEY = Buffer.from('000102030405060708090a0b0c0d0e0f', 'hex'); // lab values, never a real key
const IV = Buffer.from('a0a1a2a3a4a5a6a7a8a9aaabacadaeaf', 'hex');

const ecb = createCipheriv('aes-128-ecb', KEY, null).setAutoPadding(false).update(plaintext);
const key = await crypto.subtle.importKey('raw', KEY, 'AES-CBC', false, ['encrypt']);
const cbc = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: IV }, key, plaintext));
const ctrKey = await crypto.subtle.importKey('raw', KEY, 'AES-CTR', false, ['encrypt']);
const ctr = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CTR', counter: IV, length: 64 }, ctrKey, plaintext));

function draw(name, bytes) {
  const blocks = [];
  for (let i = 0; i < plaintext.length; i += 16) blocks.push(Buffer.from(bytes.subarray(i, i + 16)).toString('hex'));
  const rows = [];
  for (let r = 0; r < PICTURE.length; r++) rows.push(blocks.slice(r * WIDTH, (r + 1) * WIDTH).map((b) => b[0]).join(''));
  console.log(`${name}: ${blocks.length} blocks, ${new Set(blocks).size} different`);
  for (const row of rows) console.log(`  ${row}`);
}

draw('plaintext', plaintext);
draw('ECB', ecb);
draw('CBC', cbc);
draw('CTR', ctr);

Output

plaintext: 216 blocks, 2 different
  000000000000000000000000
  000000000ffffff000000000
  00000000ff0000ff00000000
  00000000ff0000ff00000000
  000000ffffffffffff000000
  000000fffff00fffff000000
  000000fffff00fffff000000
  000000ffffffffffff000000
  000000000000000000000000
ECB: 216 blocks, 2 different
  cccccccccccccccccccccccc
  ccccccccc333333ccccccccc
  cccccccc33cccc33cccccccc
  cccccccc33cccc33cccccccc
  cccccc333333333333cccccc
  cccccc33333cc33333cccccc
  cccccc33333cc33333cccccc
  cccccc333333333333cccccc
  cccccccccccccccccccccccc
CBC: 216 blocks, 216 different
  5e992d08c1ae1821be217e87
  9c10a6d7a59e5ae0864ce0fd
  4c7437deb25cc955b4d6d589
  7c7b013b6683cec749649448
  4b80b0badf3d677375042ccf
  a256fbb1704b7837f4a9e5e4
  c55e310ea3dcdcd3544caf2d
  978dac7a92b9ab82e335599d
  b104ee580940c2701254a088
CTR: 216 blocks, 216 different
  51a1be1dd6304bdae9eb31b8
  a8eadb3980717523785c4320
  cd26486c13fa72b304fa5e68
  69cc8ba59834b31c111cba17
  5c94ea81f15a9108be401432
  35df36d925426be7e18a490d
  831ca7326ffe206a7530462d
  c5ca14e956bc703495f29967
  df17b1dfdf973974b9eb2fe0

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node ecb_picture.mjs

ECB turned the 216 blocks into exactly 2 different values, so the padlock reappears in c and 3. CBC and CTR gave 216 different blocks, and the picture is gone. Real data leaks the same way, without any picture: two records with the same 16 bytes at the same position, a status, a name or a padded field, have the same ciphertext there, and anyone with read access to the table can see which records are equal and group them. Nothing in AES is broken; the mode simply has no input that changes from one call to the next.

AES is secure, so AES in ECB mode is secure

Wrong: choosing ECB because it is simple and AES is strong. Right: ASVS 5.0 requirement 11.3.1 rules out insecure block modes such as ECB, and the OWASP Cryptographic Storage Cheat Sheet allows it only in very specific circumstances. The strength of the block cipher does not help, because equal inputs give equal outputs whatever the key.

Checkpoint

Check: Why ECB shows equal blocks

  1. One question A table stores a 16-byte status field encrypted with AES-ECB under one key, one block per row. What can someone who reads the table, without the key, learn?

    Choose one answer.

    Show the answer: One question

    Answer: Which rows have the same status, and how many rows share each one

    ECB is a code book: every distinct plaintext block has one ciphertext block. Grouping rows by ciphertext groups them by plaintext, as the padlock showed with 216 blocks and 2 values.

CBC and CTR hide repeats, if the IV or counter is fresh

CBC (cipher block chaining): A mode that XORs each plaintext block with the previous ciphertext block, and the first with an unpredictable IV, before encrypting it. It needs padding and detects no changes to the ciphertext., cipher block chaining, XORs each plaintext block with the previous ciphertext block before encrypting it, and the first one with an initialization vector (IV): A value a mode mixes into each message so that equal messages encrypt differently, such as the 16 random bytes of CBC. It is stored with the ciphertext and must be unpredictable or unique, as the mode requires.:

C1=EK(P1⊕︎IV),Cj=EK(Pj⊕︎Cj−1)C_1 = E_K(P_1 \oplus IV), \qquad C_j = E_K(P_j \oplus C_{j-1})

In words: every ciphertext block depends on its own plaintext block and on every block before it, back to the IV. Two messages that start with the same 16 bytes therefore start differently, as long as their IVs differ. SP 800-38A asks more of the IV than difference: it need not be secret, but it must be unpredictable, which in practice means 16 fresh random bytes per message from a secure generator. CBC also needs whole blocks, so Web Crypto pads with PKCS#7 padding: Padding that fills the last block with 1 to 16 bytes, each equal to the number of bytes added, so that the data is a whole number of AES blocks. Web Crypto's AES-CBC uses it and always adds at least one byte., adding 1 to 16 bytes, each equal to the number of bytes added. Step through one message:

Interactive · stage by stage CBC encrypts a message block by block Step through AES-CBC on a 30-byte message, from a fresh IV to the 48 bytes that are stored.
CBC encrypts a message block by block, step 6 of 6. Showing A fresh random IV, Block 1 XOR the IV, AES-128 under the key, Block 2 XOR C1, AES-128 under the key, Stored: IV, C1, C2. In focus: Stored: IV, C1, C2.next message: new IVA fresh random IV16 bytes, unpredictableBlock 1 XOR the IVship=standard;qtAES-128 under the keygives C1Block 2 XOR C1y=0002;ref=73; + 2 bytesAES-128 under the keygives C2Stored: IV, C1, C248 bytes for 30no tag, no check

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. A new IV. CBC begins every message with an IV of 16 bytes drawn fresh at random. It is stored in the clear, but it must be unpredictable, as NIST SP 800-38A requires.
  2. Chain the first block. The first 16 bytes of the message are XORed with the IV before AES sees them, so two messages that start alike still start differently once encrypted.
  3. Encrypt it. AES-128 under the key turns the mixed block into the first ciphertext block, C1.
  4. Chain the next block. The second block is XORed with C1, not with the IV. It holds 14 bytes of the message, and PKCS#7 padding fills the last 2 bytes with the value 2.
  5. Encrypt again. AES gives C2. Each ciphertext block now depends on its own plaintext block and on every block before it, back to the IV.
  6. What is stored. The IV, C1 and C2 are stored or sent: 48 bytes for a 30-byte message. Nothing among them lets the receiver check that they are the bytes that were sent.

CTR (counter mode): A mode in which the block cipher encrypts a series of counter blocks into a keystream that is XORed with the data. It needs no padding and detects no changes, and every counter block must be unique under the key., counter mode, works differently: AES encrypts a sequence of counter blocks, and the outputs form a keystream: The sequence of key bytes that a stream cipher XORs with the plaintext. AES-CTR makes it by encrypting counter blocks; it must never repeat under one key. More in “Transposition, product ciphers and one-time pads” that is XORed with the data. It needs no padding, and the standard asks one thing: across all messages under a key, every counter block must be distinct. In Web Crypto the length parameter says how many of the rightmost bits of the 16-byte counter block are incremented. The program checks both modes against the standard’s examples and then tries a fixed IV:

CBC and CTR against SP 800-38A, padding sizes, and a fixed IV JavaScript · modes-js/cbc_and_ctr.mjs
// AES-CBC and AES-CTR in Web Crypto against NIST SP 800-38A examples F.2.1 and F.5.5, the sizes CBC's padding gives,
// and what one fixed IV does to two messages that start with the same 16 bytes.
const fromHex = (h) => Uint8Array.from(h.match(/../g).map((x) => parseInt(x, 16)));
const hex = (bytes) => Buffer.from(bytes).toString('hex');
const PLAIN = fromHex(
  '6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e5130c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710',
);

// F.2.1 CBC-AES128.Encrypt
const cbcKey = await crypto.subtle.importKey('raw', fromHex('2b7e151628aed2a6abf7158809cf4f3c'), 'AES-CBC', false, ['encrypt']);
const cbc = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: fromHex('000102030405060708090a0b0c0d0e0f') }, cbcKey, PLAIN));
const F21 = '7649abac8119b246cee98e9b12e9197d5086cb9b507219ee95db113a917678b273bed6b8e3c1743b7116e69e222295163ff1caa1681fac09120eca307586e1a7';
console.log(`CBC, F.2.1: ${PLAIN.length} bytes in, ${cbc.length} out; the first 64 as NIST's: ${hex(cbc.subarray(0, 64)) === F21}`);

// F.5.5 CTR-AES256.Encrypt: the rightmost 64 bits of the counter block are incremented
const ctrKey = await crypto.subtle.importKey('raw', fromHex('603deb1015ca71be2b73aef0857d77811f352c073b6108d72d9810a30914dff4'), 'AES-CTR', false, ['encrypt']);
const ctr = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CTR', counter: fromHex('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'), length: 64 }, ctrKey, PLAIN));
const F55 = '601ec313775789a5b7a7f504bbf3d228f443e3ca4d62b59aca84e990cacaf5c52b0930daa23de94ce87017ba2d84988ddfc9c58db67aada613c2dd08457941a6';
console.log(`CTR, F.5.5: ${PLAIN.length} bytes in, ${ctr.length} out; as NIST's: ${hex(ctr) === F55}`);

// Padding: CBC rounds every message up to whole blocks and always adds at least one byte; CTR keeps the length.
const sizes = [0, 1, 15, 16, 17, 32];
const cbcSizes = [];
const ctrSizes = [];
for (const n of sizes) {
  const data = new Uint8Array(n);
  cbcSizes.push((await crypto.subtle.encrypt({ name: 'AES-CBC', iv: new Uint8Array(16) }, cbcKey, data)).byteLength);
  ctrSizes.push((await crypto.subtle.encrypt({ name: 'AES-CTR', counter: new Uint8Array(16), length: 64 }, ctrKey, data)).byteLength);
}
console.log(`plaintext bytes   ${sizes.map((n) => String(n).padStart(3)).join('')}`);
console.log(`CBC ciphertext    ${cbcSizes.map((n) => String(n).padStart(3)).join('')}`);
console.log(`CTR ciphertext    ${ctrSizes.map((n) => String(n).padStart(3)).join('')}`);

// Two messages whose first 16 bytes are equal, under a fixed IV and under a fresh random IV each.
const enc = new TextEncoder();
const first = enc.encode('patient=4211;ward=B;drug=insulin');
const second = enc.encode('patient=4211;ward=B;drug=heparin');
const fixedIv = new Uint8Array(16);
const sameStart = async (ivA, ivB) => {
  const a = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: ivA }, cbcKey, first));
  const b = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: ivB }, cbcKey, second));
  return hex(a.subarray(0, 16)) === hex(b.subarray(0, 16));
};
console.log(`one fixed IV: the first ciphertext blocks are equal: ${await sameStart(fixedIv, fixedIv)}`);
const random = () => crypto.getRandomValues(new Uint8Array(16));
console.log(`a fresh random IV each: the first ciphertext blocks are equal: ${await sameStart(random(), random())}`);

Output

CBC, F.2.1: 64 bytes in, 80 out; the first 64 as NIST's: true
CTR, F.5.5: 64 bytes in, 64 out; as NIST's: true
plaintext bytes     0  1 15 16 17 32
CBC ciphertext     16 16 16 32 32 48
CTR ciphertext      0  1 15 16 17 32
one fixed IV: the first ciphertext blocks are equal: true
a fresh random IV each: the first ciphertext blocks are equal: false

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node cbc_and_ctr.mjs

Both modes reproduce NIST’s ciphertext; CBC’s output is 16 bytes longer because PKCS#7 always adds at least one byte, so 16 bytes of data take 32. With one fixed IV, two patient records whose first 16 bytes match produced equal first ciphertext blocks, the ECB leak again for the start of every message. With a fresh random IV each, they did not.

Where the IV comes from

Draw a new IV with crypto.getRandomValues(new Uint8Array(16)) for every CBC message and store it in front of the ciphertext. Never use a constant, a record ID or a timestamp: SP 800-38A requires CBC IVs to be unpredictable, and CTR counter blocks to be unique under the key.

Checkpoint

Check: CBC's IV and CTR's counter blocks

  1. Question 1 of 2 Web Crypto's AES-CBC encrypts a 40-byte message. How many bytes of ciphertext does it return, not counting the IV?

    Type a number.

    A hint

    PKCS#7 always adds between 1 and 16 bytes to reach a whole number of 16-byte blocks.

    Show the answer: Question 1 of 2

    Answer: 48 bytes

    40 bytes are 2 full blocks and 8 bytes more, so PKCS#7 adds 8 bytes of value 8 to fill the third block: 48 bytes. A message that is already a multiple of 16 gets a whole extra block, as 16 bytes became 32 in the program.

  2. Question 2 of 2 A service encrypts every record with AES-CBC under one key and the same IV. What does an observer of the stored records learn?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Which records start with the same 16 bytes, and with the same 32, 48 and so on

    A CBC IV must be unpredictable, which means fresh and random for every message. A fixed IV turns the start of every message into ECB.

Neither CBC nor CTR notices a changed ciphertext

CBC and CTR keep data secret. Neither checks that the bytes arriving are the bytes that were sent: SP 800-38A calls them confidentiality modes, and its Appendix D works out what a changed bit does to each. A changed IV bit changes the same bit of CBC’s first plaintext block and nothing else. Follow one message through a storage that changes a single bit:

Interactive · message by message A changed bit that AES-CBC cannot notice Step through one CBC message, stored by your service, changed by one bit on the way, and decrypted by your reader.
A changed bit that AES-CBC cannot notice, step 5 of 5. Messages so far: Your service to Storage or network: IV, C1, C2: 48 bytes; Storage or network to Your reader: the changed 48 bytes.Your serviceencrypts with AES-CBCStorage or networkIV byte 4: one bit flippedYour readership<standard;qty=0002;…IV, C1, C2: 48 bytesthe changed 48 bytesno error, a different message

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Encrypted and sent. Your service encrypts the 30-byte message with AES-CBC and sends the IV, C1 and C2, 48 bytes in all.
  2. One bit changes. On the way one bit of byte 4 of the IV changes, through a faulty disk, a bad copy or someone who can write to the storage.
  3. Delivered. The reader receives the 48 bytes. They have the right length and nothing about them looks wrong.
  4. Decrypted, no error. The reader decrypts with the right key. The padding at the end is intact, so AES-CBC returns a message and raises no error.
  5. Changed exactly there. Byte 4 of the message is now < instead of =, the same bit that changed in the IV, and the rest is intact. Nothing in CBC can tell the reader.

A format you own can be tested for this. The tamper check changes one bit of each byte of the stored IV and ciphertext in turn and counts the changed messages that decrypt without an error. A format with integrity refuses every one of them:

The tamper check on AES-CBC: 48 changes, one at a time JavaScript · tamper-js/cbc_tamper.mjs
// The tamper check, run on AES-CBC: change one bit of each byte of IV || ciphertext in turn, as corruption or
// interference in transit would, and count the changed messages that decrypt without an error. A sound format
// refuses all of them. The key and IV are fixed lab values so that the run repeats.
const fromHex = (h) => Uint8Array.from(h.match(/../g).map((x) => parseInt(x, 16)));
const shown = (bytes) => Array.from(bytes, (b) => (b >= 0x20 && b < 0x7f ? String.fromCharCode(b) : '·')).join('');

const KEY = fromHex('000102030405060708090a0b0c0d0e0f'); // lab values, never a real key
const IV = fromHex('a0a1a2a3a4a5a6a7a8a9aaabacadaeaf');
const MESSAGE = new TextEncoder().encode('ship=standard;qty=0002;ref=73;'); // 30 bytes: two blocks, the second padded

const key = await crypto.subtle.importKey('raw', KEY, 'AES-CBC', false, ['encrypt', 'decrypt']);
const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CBC', iv: IV }, key, MESSAGE));
const blob = Uint8Array.from([...IV, ...ciphertext]); // what is stored or sent: 16 + 32 bytes

const parts = [
  { name: 'in the IV', from: 0, accepted: 0 },
  { name: 'in ciphertext block 1', from: 16, accepted: 0 },
  { name: 'in ciphertext block 2, the last', from: 32, accepted: 0 },
];
const seen = new Map();
for (let i = 0; i < blob.length; i++) {
  const changed = Uint8Array.from(blob);
  changed[i] ^= 0x01;
  try {
    const opened = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: changed.subarray(0, 16) }, key, changed.subarray(16)));
    parts[Math.floor(i / 16)].accepted += 1;
    seen.set(i, shown(opened));
  } catch {
    // OperationError: here only ever because the padding of the last block no longer reads as padding
  }
}
const total = parts.reduce((n, p) => n + p.accepted, 0);
console.log(`original:                "${shown(MESSAGE)}"`);
console.log('one-bit changes that decrypted without an error:');
for (const p of parts) console.log(`  ${p.name.padEnd(32)} ${String(p.accepted).padStart(2)} of 16`);
console.log(`IV byte 4 changed:       "${seen.get(4)}"`);
console.log(`block 1 byte 3 changed:  "${seen.get(19)}"`);
console.log(`the check expects 0 of ${blob.length} accepted: ${total === 0 ? 'pass' : `FAIL, ${total} accepted`}`);

Output

original:                "ship=standard;qty=0002;ref=73;"
one-bit changes that decrypted without an error:
  in the IV                        16 of 16
  in ciphertext block 1            14 of 16
  in ciphertext block 2, the last   0 of 16
IV byte 4 changed:       "ship<standard;qty=0002;ref=73;"
block 1 byte 3 changed:  "<····H>····bs··Xy=0102;ref=73;"
the check expects 0 of 48 accepted: FAIL, 30 accepted

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node cbc_tamper.mjs

CBC accepted 30 of the 48 changes. Every IV change went through and altered exactly one byte of the message: with byte 4 of the IV changed, = became <. A change in the first ciphertext block scrambled that block and flipped the same bit in the next one, 0002 became 0102. The 18 refusals were not integrity at work: they changed the last block or its padding bytes, so the padding no longer read as padding. CTR does worse, refusing nothing, because a changed ciphertext bit changes exactly the same plaintext bit, as the tamper test of the one-time pad lesson showed. The W3C’s Web Crypto specification gives the same warning: AES in counter mode on its own lets anyone flip output bits by flipping input bits, and is safe only with a MAC built over the ciphertext and the IV.

Key idea

CBC and CTR hide data but do not protect it: changed bytes decrypt without an error, often in a predictable way, so a format that uses them needs a check of its own, and a tamper check that fails on them is the test to keep.

Checkpoint

Check: Changed ciphertext that still decrypts

  1. Question 1 of 2 In the program, AES-CBC refused 18 of the 48 one-bit changes. What refused them?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The padding check, because those changes broke the padding of the last block

    Padding errors are an accident of where the change landed, not protection. Integrity needs a tag over the whole message, which CBC does not have.

  2. Question 2 of 2 Run the tamper check on AES-CTR with a 16-byte counter block in front of a 30-byte ciphertext. Of the 46 one-bit changes, how many decrypt without an error?

    Type a number.

    A hint

    CTR has no padding, so nothing at all can fail on decryption.

    Show the answer: Question 2 of 2

    Answer: 46 changes

    CTR decrypts any bytes to some plaintext, with no padding to break. Every change goes through: a changed ciphertext bit flips the same plaintext bit, and a changed bit of the stored counter block changes the whole keystream, so every block decrypts to different bytes, still without an error.

Encrypt-then-MAC adds the missing check

Two ASVS 5.0 requirements settle what to add. Number 11.3.3 wants changes to encrypted data detected, ideally by an authenticated encryption mode, otherwise by a standard cipher paired with a standard MAC, and number 11.3.5 fixes the order of the pair: encrypt-then-MAC: Encrypting first, then computing a MAC with a separate key over the ciphertext, its IV and any header, and checking that tag before decrypting. ASVS 5.0 requires this order whenever a cipher and a MAC are combined.. Encrypt first, then compute a message authentication code (MAC): A function of a secret key and a message whose output, the tag, only holders of the key can compute. A receiver with the key recomputes the tag to check that the message is unchanged and came from a key holder; HMAC and KMAC are MACs. More in “HMAC and constant-time verification” over the ciphertext and everything the reader will rely on, and on the way back check that tag before decrypting anything. Step through the format the program uses:

Interactive · stage by stage Encrypt-then-MAC, written and read Step through sealing one 30-byte message and opening it again, in the order that keeps changed bytes away from decryption.
Encrypt-then-MAC, written and read, step 6 of 6. Showing Two keys from one root, Encrypt with AES-CTR, HMAC over the whole body, Stored: body and tag, Check the tag first, Only then decrypt. In focus: Only then decrypt.wrong: refuse, no decryptTwo keys from one rootHKDF: an enc and a mac labelEncrypt with AES-CTRa fresh counter blockHMAC over the whole bodyversion, counter, ciphertextStored: body and tag1 + 16 + 30 + 32 = 79 bytesCheck the tag firstall 32 bytes, constant timeOnly then decryptAES-CTR, the same counter

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Two keys. One root key gives two keys through HKDF, one per label. No key does both jobs, so a weakness in one use cannot reach the other.
  2. Encrypt. AES-CTR encrypts the message under the encryption key, starting from a fresh counter block of 8 random bytes and a 64-bit block counter.
  3. Tag everything. HMAC-SHA-256 under the MAC key covers the version byte, the counter block and the ciphertext, everything the reader will rely on.
  4. Store. The body and its 32-byte tag are stored together: 79 bytes for the 30-byte message.
  5. Check first. Opening starts with the tag. The reader computes it again over the body and compares all 32 bytes in constant time; a mismatch ends here with one error.
  6. Then decrypt. Only a body whose tag matched is decrypted. In the program, none of the 79 changed blobs reached a decryption.

The program builds that format with Web Crypto: two keys from one root key through HKDF, AES-256-CTR with a fresh counter block, and an HMAC-SHA-256 tag over the version byte, the counter block and the ciphertext, checked with crypto.subtle.verify before any decryption. Then it runs the tamper check of the previous section on it:

Encrypt-then-MAC with AES-CTR and HMAC-SHA-256, and the tamper check JavaScript · etm-js/etm.mjs
// Encrypt-then-MAC with Web Crypto: AES-256-CTR for confidentiality, HMAC-SHA-256 for integrity, two keys derived
// from one root key with HKDF, and a tag over the version, the counter block and the ciphertext that is checked
// before anything is decrypted. Then the tamper check of the CBC program, run on this format.
const enc = new TextEncoder();
const VERSION = 0x01;
const TAG = 32;

/** Two keys from one root key, one label each: never one key for both jobs. */
async function deriveKeys(rootBytes) {
  const root = await crypto.subtle.importKey('raw', rootBytes, 'HKDF', false, ['deriveKey']);
  const hkdf = (label) => ({ name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(0), info: enc.encode(label) });
  return {
    enc: await crypto.subtle.deriveKey(hkdf('etm v1 enc'), root, { name: 'AES-CTR', length: 256 }, false, ['encrypt', 'decrypt']),
    mac: await crypto.subtle.deriveKey(hkdf('etm v1 mac'), root, { name: 'HMAC', hash: 'SHA-256', length: 256 }, false, ['sign', 'verify']),
  };
}

class OpenError extends Error {}
let decryptions = 0;

async function seal(keys, plaintext) {
  const counter = new Uint8Array(16);
  crypto.getRandomValues(counter.subarray(0, 8)); // 8 random bytes, then a 64-bit block counter from zero
  const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, keys.enc, plaintext));
  const body = Uint8Array.from([VERSION, ...counter, ...ciphertext]);
  const tag = new Uint8Array(await crypto.subtle.sign('HMAC', keys.mac, body));
  return Uint8Array.from([...body, ...tag]);
}

async function open(keys, blob) {
  if (blob.length < 1 + 16 + TAG || blob[0] !== VERSION) throw new OpenError('cannot open');
  const body = blob.subarray(0, blob.length - TAG);
  const tag = blob.subarray(blob.length - TAG);
  if (!(await crypto.subtle.verify('HMAC', keys.mac, tag, body))) throw new OpenError('cannot open'); // checked first
  decryptions += 1;
  return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CTR', counter: body.subarray(1, 17), length: 64 }, keys.enc, body.subarray(17)));
}

const keys = await deriveKeys(new Uint8Array(32).fill(0x42)); // lab root keys, never real ones
const otherKeys = await deriveKeys(new Uint8Array(32).fill(0x43));
const text = 'ship=standard;qty=0002;ref=73;';
const blob = await seal(keys, enc.encode(text));
const opened = new TextDecoder().decode(await open(keys, blob));
console.log(`sealed: 1 + 16 + ${text.length} + ${TAG} = ${blob.length} bytes; it opens to the message: ${opened === text}`);
decryptions = 0;

let accepted = 0;
for (let i = 0; i < blob.length; i++) {
  const changed = Uint8Array.from(blob);
  changed[i] ^= 0x01;
  try {
    await open(keys, changed);
    accepted += 1;
  } catch (error) {
    if (!(error instanceof OpenError)) throw error;
  }
}
console.log(`the check expects 0 of ${blob.length} accepted: ${accepted === 0 ? 'pass' : `FAIL, ${accepted} accepted`}`);

for (const [name, bad] of [
  ['the tag cut to 16 bytes', blob.subarray(0, blob.length - 16)],
  ['the first 48 bytes only', blob.subarray(0, 48)],
  ['version 2 in front', Uint8Array.from([0x02, ...blob.subarray(1)])],
  ['sealed under another root key', await seal(otherKeys, enc.encode(text))],
]) {
  try {
    await open(keys, bad);
    console.log(`  ${name}: opened, which is wrong`);
  } catch (error) {
    console.log(`  ${name}: ${error.message}`);
  }
}
console.log(`decryptions run on changed or bad blobs: ${decryptions}`);

Output

sealed: 1 + 16 + 30 + 32 = 79 bytes; it opens to the message: true
the check expects 0 of 79 accepted: pass
  the tag cut to 16 bytes: cannot open
  the first 48 bytes only: cannot open
  version 2 in front: cannot open
  sealed under another root key: cannot open
decryptions run on changed or bad blobs: 0

Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node etm.mjs

All 79 one-bit changes were refused, and so were a cut tag, a short blob, another version and a blob sealed under another root key, each with the same error. No refused blob reached a decryption. Each rule of the format answers a failure:

  • Two keys, one label each. One key for AES and HMAC would tie their security together; HKDF gives each job its own key from one root key.
  • The tag covers everything. The version, the counter block or IV, the ciphertext and any header: a field left out of the tag can be changed, as CBC’s IV was.
  • Check first, in constant time. Verify the whole tag, then decrypt, so changed bytes never reach the cipher.
  • One error for every failure. A short blob, another version and a bad tag all say “cannot open”.

MAC-then-encrypt, and a MAC over the plaintext

Wrong: computing the MAC over the plaintext and encrypting both, or decrypting first and checking afterwards. Then the receiver decrypts and unpads bytes nobody has vouched for, and RFC 7366 notes that TLS’s MAC-then-encrypt was the subject of security vulnerabilities over many years. Right: encrypt, MAC the ciphertext with its IV and header, and check the MAC before anything else.

Encrypt-then-MAC is the fallback that OWASP’s Cryptographic Storage Cheat Sheet names when GCM or CCM is not available. Where it is available, an authenticated mode does all of this in one call, which the next lesson builds with AES-GCM and ChaCha20-Poly1305.

Checkpoint

Check: Encrypt-then-MAC

  1. Question 1 of 2 An encrypt-then-MAC format with AES-CBC tags the ciphertext but leaves the IV out of the tag. What can still change without detection?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The first plaintext block, through bits of the IV

    The tag must cover everything decryption depends on: the version, the IV or counter block, the ciphertext and any header. The W3C's note on AES-CTR says the same for its counter.

  2. Question 2 of 2 In encrypt-then-MAC, what does opening a blob do first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It checks the length and version, then the tag in constant time, and decrypts only if the tag matches

    Verify, then decrypt. In the program no changed or bad blob reached a decryption, which is what the order is for.

Practise: encrypt-then-MAC in Python

Write the format of the last section in Python; its sample tests run in the browser and check keys from HKDF, a tag over the version, the IV and the ciphertext, and opening that checks before it decrypts. The cipher is passed in, so the tests can use NIST’s published AES-CTR keystream and a stand-in, and one of them watches whether a bad blob ever reaches the cipher:

Exercise · Medium · Python

Seal and open with encrypt-then-MAC

Write the three functions of etm.py so that a stored blob is VERSION || iv || ciphertext || tag, with the tag an HMAC-SHA-256 over everything in front of it:

  • derive_keys(root_key) returns (enc_key, mac_key), two 32-byte keys from HKDF-SHA-256 with no salt and the info labels b"etm v1 enc" and b"etm v1 mac". With no salt, extract is prk = hmac.digest(bytes(32), root_key, "sha256"), and for 32 bytes expand is one block: hmac.digest(prk, info + b"\x01", "sha256").
  • seal(root_key, iv, plaintext, keystream) XORs the plaintext with keystream(enc_key, iv, len(plaintext)) and appends the tag, made with mac_key. The IV is 16 bytes; anything else raises ValueError, and so does a keystream that returns fewer bytes than the plaintext has, instead of a shorter ciphertext.
  • open_sealed(root_key, blob, keystream) checks the length and the version, then the tag with hmac.compare_digest, and only then decrypts. Every blob it cannot open, too short, another version, a changed byte anywhere, raises ValueError("cannot open"), the same message every time.

keystream is the cipher. In real code it is AES-CTR from a library; Python's standard library has no AES, so the sample tests pass in a stand-in built from HMAC-SHA-256, and also the keystream NIST SP 800-38A publishes for its example F.5.1, so that your ciphertext must equal NIST's. One test hands open_sealed a keystream that records its calls, to check that a blob with a bad tag is never decrypted.

The starter code uses one key for both jobs, tags the ciphertext alone, decrypts before it checks, compares with !=, and lets a short blob reach the slicing.

Starter code · etm.py

import hmac

VERSION = b"\x01"
IV_SIZE = 16
TAG_SIZE = 32


def derive_keys(root_key: bytes) -> tuple[bytes, bytes]:
    """(enc_key, mac_key) from HKDF-SHA-256 with no salt and the labels b"etm v1 enc" and b"etm v1 mac"."""
    return root_key, root_key  # one key for both jobs


def seal(root_key: bytes, iv: bytes, plaintext: bytes, keystream) -> bytes:
    """VERSION || iv || ciphertext || HMAC-SHA-256 tag of everything before the tag."""
    enc_key, mac_key = derive_keys(root_key)
    ciphertext = bytes(p ^ k for p, k in zip(plaintext, keystream(enc_key, iv, len(plaintext))))
    tag = hmac.digest(mac_key, ciphertext, "sha256")  # tags the ciphertext alone
    return VERSION + iv + ciphertext + tag


def open_sealed(root_key: bytes, blob: bytes, keystream) -> bytes:
    """The plaintext of a blob that seal made; ValueError("cannot open") for any other blob."""
    enc_key, mac_key = derive_keys(root_key)
    iv, ciphertext, tag = blob[1:17], blob[17:-32], blob[-32:]
    plaintext = bytes(c ^ k for c, k in zip(ciphertext, keystream(enc_key, iv, len(ciphertext))))  # decrypts first
    if tag != hmac.digest(mac_key, ciphertext, "sha256"):
        raise ValueError("tag mismatch")
    return plaintext
The sample tests · test_etm.py
import hmac

from etm import derive_keys, open_sealed, seal

ROOT = bytes(range(32))  # a lab root key
IV = bytes.fromhex("a0a1a2a3a4a5a6a7a8a9aaabacadaeaf")
MESSAGE = b"ship=standard;qty=0002;ref=73;"

# NIST SP 800-38A, example F.5.1 (CTR-AES128.Encrypt): the initial counter block, the four output blocks, which are
# the AES-CTR keystream, and the plaintext and ciphertext they connect.
F51_COUNTER = bytes.fromhex("f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff")
F51_OUTPUT = bytes.fromhex(
    "ec8cdf7398607cb0f2d21675ea9ea1e4362b7c3c6773516318a077d7fc5073ae"
    "6a2cc3787889374fbeb4c81b17ba6c44e89c399ff0f198c6d40a31db156cabfe"
)
F51_PLAIN = bytes.fromhex(
    "6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51"
    "30c81c46a35ce411e5fbc1191a0a52eff69f2445df4f9b17ad2b417be66c3710"
)
F51_CIPHER = bytes.fromhex(
    "874d6191b620e3261bef6864990db6ce9806f66b7970fdff8617187bb9fffdff"
    "5ae4df3edbd5d35e5b4f09020db03eab1e031dda2fbe03d1792170a0f3009cee"
)


def standin(key, iv, n):
    """A stand-in for AES-CTR, for these tests only: HMAC-SHA-256(key, iv || block number), block after block."""
    out = b""
    block = 0
    while len(out) < n:
        out += hmac.digest(key, iv + block.to_bytes(8, "big"), "sha256")
        block += 1
    return out[:n]


def nist(key, iv, n):
    """The keystream of example F.5.1, for its own counter block."""
    assert iv == F51_COUNTER and n <= len(F51_OUTPUT)
    return F51_OUTPUT[:n]


def outcome(blob, keystream=standin):
    try:
        open_sealed(ROOT, blob, keystream)
    except ValueError as e:
        return "refused" if str(e) == "cannot open" else f"ValueError({e})"
    except Exception as e:  # noqa: BLE001 - a bad blob must give ValueError("cannot open"), nothing else
        return f"raised {type(e).__name__}"
    return "opened"


def test_keys_from_hkdf():
    """two different 32-byte keys, each the HKDF-SHA-256 output for its label"""
    prk = hmac.digest(bytes(32), ROOT, "sha256")
    enc_key, mac_key = derive_keys(ROOT)
    assert enc_key == hmac.digest(prk, b"etm v1 enc\x01", "sha256")
    assert mac_key == hmac.digest(prk, b"etm v1 mac\x01", "sha256")
    assert enc_key != mac_key


def test_ciphertext_matches_nist_f51():
    """with NIST's keystream, the ciphertext inside the blob is F.5.1's, and the tag covers version, IV and ciphertext"""
    blob = seal(ROOT, F51_COUNTER, F51_PLAIN, nist)
    assert len(blob) == 1 + 16 + 64 + 32
    assert blob[:17] == b"\x01" + F51_COUNTER
    assert blob[17:81] == F51_CIPHER
    assert blob[81:] == hmac.digest(derive_keys(ROOT)[1], blob[:81], "sha256")
    assert open_sealed(ROOT, blob, nist) == F51_PLAIN


def test_round_trip():
    """messages of 0, 1, 16 and 30 bytes come back as they went in"""
    for message in [b"", b"x", bytes(16), MESSAGE]:
        assert open_sealed(ROOT, seal(ROOT, IV, message, standin), standin) == message


def test_every_changed_byte_refused():
    """one changed bit in any byte, version, IV, ciphertext or tag, is refused with the same error"""
    blob = seal(ROOT, IV, MESSAGE, standin)
    for i in range(len(blob)):
        changed = bytearray(blob)
        changed[i] ^= 0x01
        assert outcome(bytes(changed)) == "refused", f"byte {i}"


def test_short_blobs_and_other_keys_refused():
    """short and cut blobs, another version and another root key give ValueError("cannot open"), never IndexError"""
    blob = seal(ROOT, IV, MESSAGE, standin)
    for bad in [b"", blob[:1], blob[:17], blob[:48], blob[:-1], blob[:-16], b"\x02" + blob[1:]]:
        assert outcome(bad) == "refused", len(bad)
    other = seal(bytes(32), IV, MESSAGE, standin)
    assert outcome(other) == "refused"
    try:
        seal(ROOT, IV[:12], MESSAGE, standin)
    except ValueError:
        pass
    else:
        raise AssertionError("a 12-byte IV must raise ValueError")


def test_tagged_but_too_short_refused():
    """a blob whose tag is right but whose IV was cut to 8 bytes is still refused: the length is checked too"""
    body = b"\x01" + IV[:8]
    blob = body + hmac.digest(derive_keys(ROOT)[1], body, "sha256")
    assert outcome(blob) == "refused"


def test_short_keystream_is_an_error():
    """a keystream shorter than the data raises ValueError instead of making a shorter ciphertext"""

    def short(key, iv, n):
        return standin(key, iv, n)[:-1]

    try:
        blob = seal(ROOT, IV, MESSAGE, short)
    except ValueError:
        return
    raise AssertionError(f"seal returned {len(blob)} bytes from a keystream one byte short")


def test_verifies_before_decrypting():
    """a blob with a bad tag never reaches the keystream: decryption happens only after the tag matched"""
    calls = []

    def recording(key, iv, n):
        calls.append(n)
        return standin(key, iv, n)

    blob = seal(ROOT, IV, MESSAGE, standin)
    bad = blob[:-1] + bytes([blob[-1] ^ 0x01])
    assert outcome(bad, recording) == "refused"
    assert calls == [], "the keystream was asked for bytes before the tag was checked"
    assert open_sealed(ROOT, blob, recording) == MESSAGE
    assert calls == [len(MESSAGE)]
A hint

Build the blob in two steps: body = VERSION + iv + ciphertext, then body + hmac.digest(mac_key, body, "sha256"). Opening reverses it in the safe order: refuse anything shorter than 1 + 16 + 32 bytes or with another first byte, split off the last 32 bytes as the tag, compare it with the HMAC of the rest using hmac.compare_digest, and call keystream only after that comparison has succeeded.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

AES Encryption Tool (Developer) Load the SP 800-38A examples into CBC or CTR, check the published result and decrypt it again in your browser; the tool offers no ECB.

Last, the self-check: it opens on whichever of the five ideas your answers show to be weakest, and explains each choice:

Practice that adapts to you

Practise this lesson

13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 13 How large is an AES block?

    Choose one answer.

    Show the answer: Question 1 of 13

    Answer: 16 bytes (128 bits), whatever the key size

    AES encrypts 16-byte blocks. The key size only changes the number of rounds: 10, 12 or 14.

  2. Question 2 of 13 Why can the inverse cipher with the same key always recover the input block?

    Choose one answer.

    Show the answer: Question 2 of 13

    Answer: Under one key AES is a permutation, so every output block has exactly one input block

    A keyed permutation maps every block to one block and back. Changing the key changes the whole permutation, as 58 of 128 output bits changed for one key bit.

  3. Question 3 of 13 What does ECB do with each 16-byte block of the data?

    Choose one answer.

    Show the answer: Question 3 of 13

    Answer: It encrypts the block on its own, with nothing but the key

    Electronic codebook mode applies the block cipher to each block independently, which makes it a code book of plaintext blocks to ciphertext blocks.

  4. Question 4 of 13 Why did the padlock stay visible under ECB but vanish under CBC and CTR?

    Choose one answer.

    Show the answer: Question 4 of 13

    Answer: ECB had 2 different input blocks and so 2 output values, while CBC and CTR mixed a changing value into each block

    A mode hides repeats only if each call of the cipher sees a different input. ECB gives it the plaintext block alone, so repeats pass straight through.

  5. Question 5 of 13 What does NIST SP 800-38A require of a CBC initialization vector?

    Choose one answer.

    Show the answer: Question 5 of 13

    Answer: It need not be secret, but it must be unpredictable

    Draw 16 random bytes for each CBC message and store them in front of the ciphertext.

  6. Question 6 of 13 Web Crypto's AES-CTR takes { counter, length: 64 }. What does length: 64 mean?

    Choose one answer.

    Show the answer: Question 6 of 13

    Answer: The rightmost 64 bits of the 16-byte counter block are incremented for each block

    With length: 64, the left 8 bytes act as a nonce and the right 8 bytes count blocks. Every counter block under the key must still be unique across all messages.

  7. Question 7 of 13 Two servers share one AES-CTR key, and each starts its counter blocks at the same value after every restart. What is wrong?

    Choose one answer.

    Show the answer: Question 7 of 13

    Answer: Counter blocks repeat across messages under the key, so keystream repeats and the XOR of two plaintexts leaks

    Equal counter blocks under one key give equal keystream, and c₁ ⊕ c₂ = p₁ ⊕ p₂ follows. Give each writer its own key, or its own part of the counter block, as later lessons of this module do.

  8. Question 8 of 13 One bit of an AES-CTR ciphertext changes in storage. What does decryption return?

    Choose one answer.

    Show the answer: Question 8 of 13

    Answer: The plaintext with that same bit flipped, and no error

    CTR is malleable: every change to the ciphertext becomes the same change to the plaintext, silently.

  9. Question 9 of 13 A bit of a CBC message's IV is flipped in storage. Which part of the decrypted message changes?

    Choose one answer.

    Show the answer: Question 9 of 13

    Answer: The same bit of the first plaintext block, and nothing else

    SP 800-38A's Appendix D gives exactly this for CBC: bit errors in the IV appear in the same positions of the first plaintext block.

  10. Question 10 of 13 Which test belongs in the suite of a ciphertext format you own?

    Choose one answer.

    Show the answer: Question 10 of 13

    Answer: Change one bit of each stored byte in turn and require every changed blob to be refused

    A format with integrity refuses every change. Keep the check as a regression test, so a later change to the format cannot quietly drop the tag.

  11. Question 11 of 13 Why does the encrypt-then-MAC program derive two keys instead of using the root key for AES and HMAC?

    Choose one answer.

    Show the answer: Question 11 of 13

    Answer: Using one key for two algorithms ties their security together; HKDF gives each job its own key

    One key per algorithm and purpose: a weakness or a leak in one use cannot reach the other.

  12. Question 12 of 13 Which fields must the tag of an encrypt-then-MAC format cover?

    Choose every answer that is right.

    Show the answer: Question 12 of 13

    Answer:

    • The IV or counter block
    • The version byte
    • The ciphertext

    Everything the reader will use from the blob goes under the tag: here the version, the counter block and the ciphertext, and any header a later format adds.

  13. Question 13 of 13 Why should opening fail with the same error for a short blob, an unknown version and a bad tag?

    Choose one answer.

    Show the answer: Question 13 of 13

    Answer: Different errors tell whoever sends blobs which check failed, and that kind of answer has let attackers learn plaintext from CBC systems

    Log the reason for your own diagnosis, never the key or plaintext, and give callers one error. The nonce lesson returns to distinguishable errors in detail.

Interview questions

  1. Fresher to mid · Warm-up

    What does a block cipher mode of operation do, and why is ECB a poor choice?

    A block cipher such as AES encrypts exactly one 16-byte block under a key, and the same block under the same key always gives the same output. A mode of operation applies it to data of any length: it decides how the data is cut into blocks, what changes from one call to the next, such as CBC's chaining from a random IV or CTR's counter blocks, and what is stored with the ciphertext. ECB adds nothing: every block is encrypted on its own, so equal plaintext blocks give equal ciphertext blocks, and repeated fields show through, as in a padlock picture whose 216 blocks encrypted to just 2 values. ASVS 5.0 rules it out. CBC and CTR only hide data and detect no changes, so they need encrypt-then-MAC, or an authenticated mode such as AES-GCM takes their place.

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What does AES do on its own, and what can a block cipher not do?

    AES is a keyed permutation of 16-byte blocks, with a key of 128, 192 or 256 bits and 10, 12 or 14 rounds. It handles whole blocks only (17 bytes are refused), and the same block under the same key always gives the same output. A mode of operation decides how longer data is cut into blocks, what is mixed in and what extra value, an IV or a counter, is stored. Web Crypto offers AES only through modes.

  2. Why does ECB mode leak, even though AES is strong?

    ECB cuts the data into 16-byte blocks and encrypts every block separately, and SP 800-38A notes that under one key a given plaintext block always comes out as the same ciphertext block. The lesson's padlock picture of 216 blocks became just 2 distinct ciphertext values, and rows holding the same 16 bytes at the same position look identical to anyone reading the table. AES is not broken; the mode has nothing that changes from one call to the next. ASVS 5.0 (11.3.1) rules it out.

  3. How does CBC chain blocks, and what must its IV be?

    C1 = E_K(P1 ⊕ IV) and Cj = E_K(Pj ⊕ C(j−1)), so every block depends on all before it. The IV need not be secret but must be unpredictable (SP 800-38A): 16 fresh random bytes per message, stored in front of the ciphertext, never a constant, record ID or timestamp. A fixed IV brought back the ECB leak for the start of each message. PKCS#7 padding adds 1 to 16 bytes, each equal to the number added.

  4. How does CTR mode work, and what must never happen?

    AES encrypts a sequence of counter blocks and the outputs form a keystream that is XORed with the data, so no padding is needed. Across all messages under a key, every counter block must be distinct. In Web Crypto, the length parameter says how many of the rightmost bits of the 16-byte counter block are incremented.

  5. Does CBC or CTR notice a changed ciphertext?

    No: SP 800-38A calls them confidentiality modes. CBC accepted 30 of 48 one-bit changes: every IV change went through and altered one byte of the message, and a change in the first ciphertext block scrambled it and flipped the same bit in the next. The 18 refusals came only from broken padding. CTR refused nothing, because a changed ciphertext bit flips exactly the same plaintext bit.

  6. What does ASVS 5.0 require to detect changes to encrypted data?

    Requirement 11.3.3: detect changes, ideally with an authenticated encryption mode, otherwise with a standard cipher paired with a standard MAC. Requirement 11.3.5 fixes the order of that pair: encrypt-then-MAC. OWASP's Cryptographic Storage Cheat Sheet names it as the fallback when GCM or CCM is not available.

  7. What does an encrypt-then-MAC format need to be safe?

    Encrypt first, then compute a MAC over the ciphertext and everything the reader relies on, and check the tag before decrypting anything. Use two keys from one root key through HKDF, one label each; cover the version, the IV or counter block, the ciphertext and any header; verify in constant time first; and give one error for every failure. All 79 one-bit changes were refused.

  8. Why is MAC-then-encrypt, or decrypting before checking, the wrong order?

    The receiver then decrypts and unpads bytes nobody has vouched for. RFC 7366 notes that TLS's MAC-then-encrypt was the subject of security vulnerabilities over many years. Encrypt, MAC the ciphertext with its IV and header, and check the MAC before anything else.

Key takeaways

  • AES is a keyed permutation of 16-byte blocks: the same block under the same key always gives the same output, so data of any other length needs a mode of operation.
  • ECB encrypts every block on its own, so equal blocks stay equal: the padlock’s 216 blocks became 2 values, and ASVS 5.0 rules the mode out.
  • CBC chains blocks from an unpredictable random IV and pads with PKCS#7; CTR XORs a keystream of counter blocks that must never repeat under the key.
  • Neither CBC nor CTR detects changes: CBC accepted 30 of 48 one-bit changes, and its refusals came only from broken padding.
  • Encrypt-then-MAC with separate keys, a tag over the version, the IV and the ciphertext, and the tag checked before decryption refused all 79 changes.

Terms in this lesson

block cipher
A cipher that encrypts one block of a fixed size under a key, 16 bytes for AES. Under one key it is a permutation: every block maps to exactly one ciphertext block, and the inverse cipher maps it back.
CBC (cipher block chaining)
A mode that XORs each plaintext block with the previous ciphertext block, and the first with an unpredictable IV, before encrypting it. It needs padding and detects no changes to the ciphertext.
CTR (counter mode)
A mode in which the block cipher encrypts a series of counter blocks into a keystream that is XORed with the data. It needs no padding and detects no changes, and every counter block must be unique under the key.
ECB (electronic codebook)
The mode that encrypts every block on its own under the key, so equal plaintext blocks give equal ciphertext blocks and patterns show through. ASVS 5.0 rules it out.
encrypt-then-MAC
Encrypting first, then computing a MAC with a separate key over the ciphertext, its IV and any header, and checking that tag before decrypting. ASVS 5.0 requires this order whenever a cipher and a MAC are combined.
initialization vector (IV)
A value a mode mixes into each message so that equal messages encrypt differently, such as the 16 random bytes of CBC. It is stored with the ciphertext and must be unpredictable or unique, as the mode requires.
mode of operation
The rule that applies a block cipher to data of any length: how the data is cut into blocks, what is mixed into each call, such as an IV or a counter, and what is stored. ECB, CBC, CTR and GCM are modes.
PKCS#7 padding
Padding that fills the last block with 1 to 16 bytes, each equal to the number of bytes added, so that the data is a whole number of AES blocks. Web Crypto's AES-CBC uses it and always adds at least one byte.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.