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.
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- One block out. Out comes another 16-byte block, 3925841d…0b32, exactly the value the standard prints and the program reproduces.
- 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.
- 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 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
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 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
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.:
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- Encrypt it. AES-128 under the key turns the mixed block into the first ciphertext block, C1.
- 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.
- 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.
- 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:
// 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
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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Encrypted and sent. Your service encrypts the 30-byte message with AES-CBC and sends the IV, C1 and C2, 48 bytes in all.
- 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.
- Delivered. The reader receives the 48 bytes. They have the right length and nothing about them looks wrong.
- 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.
- 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, 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
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- Store. The body and its 32-byte tag are stored together: 79 bytes for the 30-byte message.
- 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.
- 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 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
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 labelsb"etm v1 enc"andb"etm v1 mac". With no salt, extract isprk = 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 withkeystream(enc_key, iv, len(plaintext))and appends the tag, made withmac_key. The IV is 16 bytes; anything else raisesValueError, 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 withhmac.compare_digest, and only then decrypts. Every blob it cannot open, too short, another version, a changed byte anywhere, raisesValueError("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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
Interview questions
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
- FIPS 197: Advanced Encryption Standard (AES) (National Institute of Standards and Technology (NIST))
- SP 800-38A: Recommendation for Block Cipher Modes of Operation, Methods and Techniques (National Institute of Standards and Technology (NIST))
- Web Cryptography Level 2 (AES-CTR, AES-CBC and the note on unauthenticated encryption) (World Wide Web Consortium (W3C))
- RFC 2315: PKCS #7, Cryptographic Message Syntax Version 1.5 (section 10.3, padding) (Internet Engineering Task Force (IETF))
- RFC 7366: Encrypt-then-MAC for Transport Layer Security (TLS) and Datagram TLS (DTLS) (Internet Engineering Task Force (IETF))
- RFC 5869: HMAC-based Extract-and-Expand Key Derivation Function (HKDF) (Internet Engineering Task Force (IETF))
- OWASP Application Security Verification Standard 5.0, V11 Cryptography (requirements 11.2.5 and 11.3.1 to 11.3.5) (OWASP Foundation)
- OWASP Cryptographic Storage Cheat Sheet (cipher modes) (OWASP Foundation)
- Node.js 24 documentation: Crypto (createCipheriv and setAutoPadding) (OpenJS Foundation (Node.js))
- Node.js 24 documentation: Web Crypto API (OpenJS Foundation (Node.js))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress