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 2 – Classical ciphers and cryptanalysis

Transposition, product ciphers and one-time pads

Rail fence and columnar transposition, confusion and diffusion in product ciphers, and the one-time pad: what a reused keystream leaks and how to stop it.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0, Python 3.14.8, Pyodide 314.0.7 and quickjs 0.32.0
  • By MySmartCoPilot
Free lesson, Pro answers: 25 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Encrypt and decrypt with rail fence and columnar transposition, reproducing the syllabus examples
  • Show that transposition keeps letter statistics, and detect it with the index of coincidence and letter pairs
  • Explain confusion, diffusion and product ciphers as the design idea behind the rounds of AES
  • State the conditions of a one-time pad and show what keystream reuse leaks
  • Detect a reused keystream or an unauthenticated stream in code you own, and fix it with unique counters and an authenticated cipher

Before you start

What you’ll learn · Before you start

What you will learn

  • Encrypt and decrypt with rail fence and columnar transposition, reproducing the syllabus examples
  • Show that transposition keeps letter statistics, and detect it with the index of coincidence and letter pairs
  • Explain confusion, diffusion and product ciphers as the design idea behind the rounds of AES
  • State the conditions of a one-time pad and show what keystream reuse leaks
  • Detect a reused keystream or an unauthenticated stream in code you own, and fix it with unique counters and an authenticated cipher

Before you start

On this page

The one-time pad is the only cipher in this module that no amount of computing can break, and the Handbook of Applied Cryptography states its cost exactly: a random key at least as long as the message, used once. Use a pad twice and two payment messages XOR to zero in 18 of their 23 bytes, wherever they agree, without any key at all.

This lesson finishes the classical ciphers and joins them to the modern ones. You write the rail fence and columnar transpositions of the syllabus, recognise a transposition from two numbers, combine substitution and transposition into a product cipher and measure why one round is not enough, and then build up from the one-time pad to AES-CTR, with a test for the keystream reuse it must never allow and a test for the tampering it cannot detect.

Transposition moves letters and changes none

A transposition cipher: A cipher that moves the characters of the plaintext to new positions and changes none of them, so the ciphertext keeps every letter count of the plaintext. keeps every letter and moves it to a new place; the Handbook’s Definition 1.32 calls it a permutation of the positions of a block. The rail fence cipher: A transposition that writes the text in a zigzag over a number of rails, down to the bottom rail and back up, then reads the rails in order. Its key, the number of rails, is easy to search. writes the text in a zigzag over a number of rails and reads the rails in order. Step through MEETATNOON on 3 rails and watch the rail r turn at the top and at the bottom:

Interactive · line by line MEETATNOON written on 3 rails in a zigzag The flag under text is the letter the loop reads next. Watch r, the rail that takes it, and step, which turns at the top and the bottom rail.

Program · rail-trace/rail_trace.py

text = "MEETATNOON"
n = 3
rails = [""] * n
r, step = 0, 1
for ch in text:
    rails[r] += ch
    if r == 0:
        step = 1
    elif r == n - 1:
        step = -1
    r += step
print("".join(rails))

Variables

After line 12: text is 'MEETATNOON'; n is 3; rails is ['MAO', 'ETTON', 'EN']; r is 2; step is 1; ch is 'N'.textM0E1E2T3A4T5N6O7O8N9n3rails'MAO'0'ETTON'1'EN'2r2step1ch'N'
  • just taken by the loop
  • changed in this step
  • passed by the loop
  • read next
  • where the loop stopped

Printed so far

MAOETTONEN

Every step was recorded from a run with Python 3.14.8 on macOS 26 arm64.

The drawing shows the variables when the program ends, and the program's 59 steps are listed below.

Line run 12
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. text = "MEETATNOON" sets text to 'MEETATNOON'.
  3. Line 2. n = 3 sets n to 3.
  4. Line 3. rails = [""] * n sets rails to ['', '', ''].
  5. Line 4. r, step = 0, 1 sets r to 0 and sets step to 1.
  6. Line 5. for ch in text: takes the character at position 0 of text: ch is 'M'.
  7. Line 6. rails[r] += ch changes rails: it is now ['M', '', '']. Key point M goes on rail 0. Every letter keeps its own value; only its position will change.
  8. Line 7. r == 0 is true (r is 0), so the block under if r == 0: runs.
  9. Line 8. step = 1 step keeps its value, 1.
  10. Line 11. r += step changes r from 0 to 1.
  11. Line 5. for ch in text: takes the character at position 1 of text: ch is 'E'.
  12. Line 6. rails[r] += ch changes rails: it is now ['M', 'E', ''].
  13. Line 7. r == 0 is false (r is 1), so Python tests the next condition.
  14. Line 9. r == n - 1 is false (r is 1 and n is 3), so Python skips the block under elif r == n - 1:.
  15. Line 11. r += step changes r from 1 to 2.
  16. Line 5. for ch in text: takes the character at position 2 of text: ch is 'E'.
  17. Line 6. rails[r] += ch changes rails: it is now ['M', 'E', 'E'].
  18. Line 7. r == 0 is false (r is 2), so Python tests the next condition.
  19. Line 9. r == n - 1 is true (r is 2 and n is 3), so the block under elif r == n - 1: runs.
  20. Line 10. step = -1 sets step to -1 (it was 1). The third letter reached the bottom rail, so step turns to -1 and the zigzag climbs back up.
  21. Line 11. r += step changes r from 2 to 1.
  22. Line 5. for ch in text: takes the character at position 3 of text: ch is 'T'.
  23. Line 6. rails[r] += ch changes rails: it is now ['M', 'ET', 'E'].
  24. Line 7. r == 0 is false (r is 1), so Python tests the next condition.
  25. Line 9. r == n - 1 is false (r is 1 and n is 3), so Python skips the block under elif r == n - 1:.
  26. Line 11. r += step changes r from 1 to 0.
  27. Line 5. for ch in text: takes the character at position 4 of text: ch is 'A'.
  28. Line 6. rails[r] += ch changes rails: it is now ['MA', 'ET', 'E'].
  29. Line 7. r == 0 is true (r is 0), so the block under if r == 0: runs.
  30. Line 8. step = 1 sets step to 1 (it was -1).
  31. Line 11. r += step changes r from 0 to 1.
  32. Line 5. for ch in text: takes the character at position 5 of text: ch is 'T'.
  33. Line 6. rails[r] += ch changes rails: it is now ['MA', 'ETT', 'E'].
  34. Line 7. r == 0 is false (r is 1), so Python tests the next condition.
  35. Line 9. r == n - 1 is false (r is 1 and n is 3), so Python skips the block under elif r == n - 1:.
  36. Line 11. r += step changes r from 1 to 2.
  37. Line 5. for ch in text: takes the character at position 6 of text: ch is 'N'.
  38. Line 6. rails[r] += ch changes rails: it is now ['MA', 'ETT', 'EN'].
  39. Line 7. r == 0 is false (r is 2), so Python tests the next condition.
  40. Line 9. r == n - 1 is true (r is 2 and n is 3), so the block under elif r == n - 1: runs.
  41. Line 10. step = -1 sets step to -1 (it was 1).
  42. Line 11. r += step changes r from 2 to 1.
  43. Line 5. for ch in text: takes the character at position 7 of text: ch is 'O'.
  44. Line 6. rails[r] += ch changes rails: it is now ['MA', 'ETTO', 'EN'].
  45. Line 7. r == 0 is false (r is 1), so Python tests the next condition.
  46. Line 9. r == n - 1 is false (r is 1 and n is 3), so Python skips the block under elif r == n - 1:.
  47. Line 11. r += step changes r from 1 to 0.
  48. Line 5. for ch in text: takes the character at position 8 of text: ch is 'O'.
  49. Line 6. rails[r] += ch changes rails: it is now ['MAO', 'ETTO', 'EN'].
  50. Line 7. r == 0 is true (r is 0), so the block under if r == 0: runs.
  51. Line 8. step = 1 sets step to 1 (it was -1).
  52. Line 11. r += step changes r from 0 to 1.
  53. Line 5. for ch in text: takes the character at position 9 of text: ch is 'N'.
  54. Line 6. rails[r] += ch changes rails: it is now ['MAO', 'ETTON', 'EN'].
  55. Line 7. r == 0 is false (r is 1), so Python tests the next condition.
  56. Line 9. r == n - 1 is false (r is 1 and n is 3), so Python skips the block under elif r == n - 1:.
  57. Line 11. r += step changes r from 1 to 2.
  58. Line 5. for ch in text: finds no character at position 10 of text (it has 10 characters), so the loop ends.
  59. Line 12. print("".join(rails)) prints MAOETTONEN. That was the last line, so the program ends. Reading the rails in order, MAO, ETTON and EN, gives MAOETTONEN: the same ten letters in a new order.

The columnar transposition: A transposition that writes the text in rows as wide as the key, fills the last row, and reads whole columns in the order of the key's digits or letters. writes the text in rows as wide as its key, fills the last row with filler letters, and reads whole columns in the order of the key’s digits. The Handbook’s Example 7.43 shows the bare idea: the permutation (6 4 1 3 5 2) reads CAESAR as RSCEAA. The program runs all three, prints the columnar grid and decrypts:

Rail fence and columnar transposition on the syllabus cases Python · transposition/transposition.py
"""Rail fence and columnar transposition on the GTU 3161606 practical 10 cases, and the Handbook's Example 7.43."""


def rail_fence(text, rails, decrypt=False):
    """Write the letters in a zigzag over the rails and read the rails in order (or undo it)."""
    pattern, r, step = [], 0, 1
    for _ in text:  # the rail of each position: 0, 1, ..., rails - 1, then back
        pattern.append(r)
        if rails > 1:
            if r == 0:
                step = 1
            elif r == rails - 1:
                step = -1
            r += step
    order = sorted(range(len(text)), key=lambda i: (pattern[i], i))  # positions, rail by rail
    if not decrypt:
        return "".join(text[i] for i in order)
    out = [""] * len(text)
    for ch, i in zip(text, order):
        out[i] = ch
    return "".join(out)


def columnar(text, key, filler="XYZ", decrypt=False):
    """Write the text in rows under the key's digits; read the columns in the order the digits give."""
    width = len(key)
    order = sorted(range(width), key=lambda c: (key[c], c))  # the column labelled 1 first, then 2 ...
    if decrypt:
        rows = len(text) // width
        columns = {c: text[k * rows:(k + 1) * rows] for k, c in enumerate(order)}
        return "".join(columns[c][r] for r in range(rows) for c in range(width))
    text += filler[: -len(text) % width]
    rows = [text[i:i + width] for i in range(0, len(text), width)]
    for row in rows:
        print("   ", " ".join(row))
    return "".join(row[c] for c in order for row in rows)


print("rail fence, 2 rails: Meetme ->", rail_fence("MEETME", 2), "->", rail_fence(rail_fence("MEETME", 2), 2, True))
print("rail fence, 3 rails: MEETATNOON ->", rail_fence("MEETATNOON", 3))
print("columnar, key 4312567, rows under the key:")
secret = columnar("ATTACKPOSTPONEDUNTILTWOAM", "4312567")
print("ciphertext:", secret)
print("decrypted: ", columnar(secret, "4312567", decrypt=True))

# Handbook Example 7.43: a fixed permutation of 6 positions, e = (6 4 1 3 5 2).
e = (6, 4, 1, 3, 5, 2)
print("CAESAR under e = (6 4 1 3 5 2):", "".join("CAESAR"[i - 1] for i in e))

Output

rail fence, 2 rails: Meetme -> MEMETE -> MEETME
rail fence, 3 rails: MEETATNOON -> MAOETTONEN
columnar, key 4312567, rows under the key:
    A T T A C K P
    O S T P O N E
    D U N T I L T
    W O A M X Y Z
ciphertext: TTNAAPTMTSUOAODWCOIXKNLYPETZ
decrypted:  ATTACKPOSTPONEDUNTILTWOAMXYZ
CAESAR under e = (6 4 1 3 5 2): RSCEAA

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 transposition.py

Under the key 4312567 the digit 1 stands over the third column, so TTNA comes first, then APTM under the 2, and the ciphertext is TTNAAPTMTSUOAODWCOIXKNLYPETZ. Decryption restores the plaintext with its filler, XYZ, which the receiver must recognise and drop.

The GTU practical

Practical 10 of Gujarat Technological University’s syllabus for Cryptography and Network Security (3161606) sets both ciphers: Meetme becomes MEMETE, the rail fence on two rails, and the key 4312567 turns “attackpostponeduntiltwoam” into TTNAAPTMTSUOAODWCOIXKNLYPETZ. It also asks for an analysis of their strength, which the next section gives.

The JavaScript version prints the same results:

The same transpositions in JavaScript JavaScript · transposition-js/transposition.mjs
// Rail fence and columnar transposition in JavaScript, on the same GTU 3161606 practical 10 cases.

/** The rail of every position: 0, 1, ..., rails - 1, then back up, and again. */
function railPattern(length, rails) {
  const pattern = [];
  let r = 0;
  let step = 1;
  for (let i = 0; i < length; i++) {
    pattern.push(r);
    if (rails > 1) {
      if (r === 0) step = 1;
      else if (r === rails - 1) step = -1;
      r += step;
    }
  }
  return pattern;
}

function railFence(text, rails, decrypt = false) {
  const pattern = railPattern(text.length, rails);
  const order = [...text].map((_, i) => i).sort((a, b) => pattern[a] - pattern[b] || a - b);
  if (!decrypt) return order.map((i) => text[i]).join('');
  const out = new Array(text.length);
  order.forEach((i, k) => (out[i] = text[k]));
  return out.join('');
}

function columnar(text, key, filler = 'XYZ', decrypt = false) {
  const width = key.length;
  const order = [...key].map((_, c) => c).sort((a, b) => key[a].localeCompare(key[b]) || a - b);
  if (decrypt) {
    const rows = text.length / width;
    const columns = {};
    order.forEach((c, k) => (columns[c] = text.slice(k * rows, (k + 1) * rows)));
    let out = '';
    for (let r = 0; r < rows; r++) for (let c = 0; c < width; c++) out += columns[c][r];
    return out;
  }
  const padded = text + filler.slice(0, (width - (text.length % width)) % width);
  let out = '';
  for (const c of order) for (let r = 0; r < padded.length / width; r++) out += padded[r * width + c];
  return out;
}

console.log('rail fence, 2 rails:', railFence('MEETME', 2), '->', railFence(railFence('MEETME', 2), 2, true));
const secret = columnar('ATTACKPOSTPONEDUNTILTWOAM', '4312567');
console.log('columnar, key 4312567:', secret);
console.log('decrypted:', columnar(secret, '4312567', 'XYZ', true));

Output

rail fence, 2 rails: MEMETE -> MEETME
columnar, key 4312567: TTNAAPTMTSUOAODWCOIXKNLYPETZ
decrypted: ATTACKPOSTPONEDUNTILTWOAMXYZ

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

A rail fence that wraps instead of turning

Wrong: rail i mod n, which writes rails 0, 1, 2, 0, 1, 2 like a saw. Right: 0, 1, 2, 1, 0, 1, 2, turning at both ends. On 2 rails the two give the same output, so only a test with 3 or more rails catches the slip; the lesson’s first exercise has one.

Checkpoint

Check: Rail fence and columnar transposition

  1. Question 1 of 2 What does the rail fence cipher with 2 rails make of MEETME?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: MEMETE

    The zigzag on 2 rails alternates top and bottom. Reading the rails in order gives MEMETE, the test case of GTU 3161606, practical 10.

  2. Question 2 of 2 With the columnar key 4312567, which column of the grid is read first?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The third column, which the key labels 1

    The key 4312567 puts 1 over the third column, so the cipher reads TTNA first, then APTM under the 2, and so on, which is how TTNAAPTMTSUOAODWCOIXKNLYPETZ begins.

Two numbers tell transposition from substitution

A transposition keeps every letter, so it keeps every letter count, and the Handbook’s Note 7.47 points out that this makes it easy to recognise. Two numbers sort the classical ciphers without any key: the index of coincidence, which the substitution of the second lesson also keeps, and a chi-squared score against English of the ciphertext as it stands, which only a transposition keeps low, because its letters are still the plaintext’s own. A third number shows what a transposition gives away: the share of adjacent pairs that are among the Handbook’s 15 most common English digram: A pair of adjacent letters, such as TH or HE, also called a digraph or bigram. Pair statistics break ciphers, such as Playfair, that hide the counts of single letters. More in “Playfair, Vigenère and Hill ciphers”. The program measures all three for the passage under five ciphers:

Index, chi-squared and common pairs, and the rail count found by search Python · detect/detect.py
"""Telling transposition from substitution by two numbers, then finding a rail-fence key by common letter pairs."""
from collections import Counter

UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
ENGLISH = [8.04, 1.54, 3.06, 3.99, 12.51, 2.30, 1.96, 5.49, 7.26, 0.16, 0.67, 4.14, 2.53,
           7.09, 7.60, 2.00, 0.11, 6.12, 6.54, 9.25, 2.71, 0.99, 1.92, 0.19, 1.73, 0.09]  # Handbook, Figure 7.5
COMMON_PAIRS = {"AN", "AT", "ED", "EN", "ER", "ES", "HE", "IN", "ON", "OR", "RE", "ST", "TE", "TH", "TI"}  # Figure 7.6
PASSAGE = (
    "When the first heavy rain reached the coast, the old bridge by the station was closed, "
    "and every bus on the morning route had to wait. The driver of the last bus told his "
    "passengers about his grandfather, who built fishing boats in a village south of the river "
    "and never once let water into a hull. He described how the wood was chosen, how the planks "
    "were bent over a slow fire and how the seams were packed with cotton and oil. By the time "
    "the bridge opened again, the children at the back could name every part of a boat, and "
    "nobody on the bus minded the delay. Their parents had missed the start of work, but they "
    "agreed that it was the most useful hour the route had ever given them. That evening, a few "
    "of them walked down to the river to watch the boats come in."
)
PLAIN = "".join(ch for ch in PASSAGE.upper() if ch in UPPER)


def rail_fence(text, rails, decrypt=False):
    pattern, r, step = [], 0, 1
    for _ in text:
        pattern.append(r)
        if r == 0:
            step = 1
        elif r == rails - 1:
            step = -1
        r += step
    order = sorted(range(len(text)), key=lambda i: (pattern[i], i))
    if not decrypt:
        return "".join(text[i] for i in order)
    out = [""] * len(text)
    for ch, i in zip(text, order):
        out[i] = ch
    return "".join(out)


def columnar(text, key):
    order = sorted(range(len(key)), key=lambda c: (key[c], c))
    text += "X" * (-len(text) % len(key))  # fill the last row
    return "".join(text[r + c] for c in order for r in range(0, len(text), len(key)))


def shift(text, k):
    return "".join(UPPER[(UPPER.index(ch) + k) % 26] for ch in text)


def substitute(text, alphabet):
    return "".join(alphabet[UPPER.index(ch)] for ch in text)


def vigenere(text, key):
    return "".join(UPPER[(UPPER.index(ch) + UPPER.index(key[i % len(key)])) % 26] for i, ch in enumerate(text))


def index_of_coincidence(text):
    counts = Counter(text)
    return sum(f * (f - 1) for f in counts.values()) / (len(text) * (len(text) - 1))


def chi_squared(text):
    """Distance from English letter counts, as the text stands: small means the letters are English letters."""
    counts = Counter(text)
    n = len(text)
    return sum((counts[ch] - n * f / 100) ** 2 / (n * f / 100) for ch, f in zip(UPPER, ENGLISH))


def common_pair_share(text):
    return sum(text[i:i + 2] in COMMON_PAIRS for i in range(len(text) - 1)) / (len(text) - 1)


print("cipher                   index  chi-squared  common pairs")
for name, text in [
    ("none, the plaintext", PLAIN),
    ("rail fence, 5 rails", rail_fence(PLAIN, 5)),
    ("columnar, key 4312567", columnar(PLAIN, "4312567")),
    ("Caesar, key 3", shift(PLAIN, 3)),
    ("keyword, KRYPTOS", substitute(PLAIN, "KRYPTOSABCDEFGHIJLMNQUVWXZ")),
    ("Vigenere, MONSOON", vigenere(PLAIN, "MONSOON")),
]:
    print(f"{name:23} {index_of_coincidence(text):.4f} {chi_squared(text):10.1f} {100 * common_pair_share(text):9.1f} %")

# The rail fence has one small key: try every rail count and keep the decryption richest in common pairs.
secret = rail_fence(PLAIN, 5)
for rails in range(2, 11):
    share = common_pair_share(rail_fence(secret, rails, decrypt=True))
    print(f"  decrypted with {rails:2} rails: {100 * share:4.1f} % of adjacent pairs are common English pairs")

Output

cipher                   index  chi-squared  common pairs
none, the plaintext     0.0692       51.5      26.7 %
rail fence, 5 rails     0.0692       51.5      10.1 %
columnar, key 4312567   0.0683       57.0       9.5 %
Caesar, key 3           0.0692     4050.3       2.8 %
keyword, KRYPTOS        0.0692     1207.2       8.4 %
Vigenere, MONSOON       0.0483     3364.1       1.3 %
  decrypted with  2 rails: 11.1 % of adjacent pairs are common English pairs
  decrypted with  3 rails: 11.3 % of adjacent pairs are common English pairs
  decrypted with  4 rails: 12.1 % of adjacent pairs are common English pairs
  decrypted with  5 rails: 26.7 % of adjacent pairs are common English pairs
  decrypted with  6 rails: 13.6 % of adjacent pairs are common English pairs
  decrypted with  7 rails: 12.6 % of adjacent pairs are common English pairs
  decrypted with  8 rails: 11.4 % of adjacent pairs are common English pairs
  decrypted with  9 rails: 11.3 % of adjacent pairs are common English pairs
  decrypted with 10 rails:  9.6 % of adjacent pairs are common English pairs

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 detect.py

The output gives a rule you can apply to any classical ciphertext:

Index of coincidence Chi-squared as it stands Common pairs Most likely
about 0.069 low, 51.5 to 57.0 broken, about 10 % a transposition
about 0.069 high, 1,207 to 4,050 broken a substitution
below 0.06 high broken several alphabets, or pairs

The broken pairs also give the key away. The Handbook notes that the top 15 digrams make up 27 % of all pairs in English, and the passage itself scores 26.7 %. A rail fence has only a handful of possible keys, so the program decrypts with every rail count from 2 to 10: the 5-rail decryption, the true key, restores 26.7 % common pairs, while every other stays at 13.6 % or below. Press Edit, change the 5 in secret = rail_fence(PLAIN, 5) to another rail count and run it: the peak follows. The Handbook compares solving a transposition to solving an anagram, with pairs and triples confirming each step.

Checkpoint

Check: Recognising a transposition

  1. One question An unknown ciphertext has an index of coincidence of 0.0692 and a chi-squared score of 51.5 against English, with no decryption at all, but only 10.1 % of its adjacent pairs are common English pairs. What is it?

    Choose one answer.

    Show the answer: One question

    Answer: A transposition of English text

    The index says one alphabet; the low chi-squared score says the letters are the plaintext's own; the broken pairs say they were moved. Together they identify a transposition, as the lesson's rail fence row shows.

Product ciphers stack substitution and transposition

Substitution hides the letters’ names and keeps their counts; transposition hides their order and keeps the letters. The Handbook’s section 1.5.3 composes the two into a product cipher: A cipher built by composing simpler steps, typically substitution and transposition, into rounds that are repeated. DES and AES are product ciphers with many rounds., calls a substitution followed by a transposition a round, and in Remark 1.36 names what each step adds: confusion: The property, added by a cipher's substitution steps, that makes the relation between the key and the ciphertext as complex as possible. Shannon named it together with diffusion. from the substitution, a complicated relation between key and ciphertext, and diffusion: The property, added by a cipher's transposition and mixing steps, that spreads the plaintext's structure over the whole ciphertext, so that one changed input bit changes about half the output bits. from the transposition, the plaintext’s redundancy spread over the ciphertext. The Handbook credits the idea to Shannon. Step through one classical round and the rounds of AES:

Interactive · stage by stage One round of substitution and transposition, repeated Step through a product cipher, first the classical version of this lesson, then the rounds of AES-128.
One round of substitution and transposition, repeated, step 7 of 7. Showing Plaintext block, Substitute (S-box, non-linear), Transpose and mix (ShiftRows, MixColumns), Mix in the round key (AddRoundKey), Ciphertext block (63.83 of 128 bits change).next roundPlaintext blockletters, or 16 bytesSubstituteS-box, non-linearTranspose and mixShiftRows, MixColumnsMix in the round keyAddRoundKeyCiphertext block63.83 of 128 bits change

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

Every step as text
  1. Two weak steps. The classical product of this lesson is one Caesar shift followed by one columnar transposition. Each step on its own is easy to break, and the Handbook calls a substitution followed by a transposition a round.
  2. Substitution adds confusion. A substitution makes the relation between key and ciphertext complicated. A Caesar shift does that badly, since it renames letters and keeps every count.
  3. Transposition adds diffusion. A transposition spreads the plaintext's structure across the ciphertext. Columnar transposition only moves letters, so one changed letter still changes exactly one letter of the output.
  4. The classical result. The output keeps the plaintext's index of coincidence exactly, and in the lesson's run a changed plaintext letter changed 1 of 28 ciphertext letters every time.
  5. The modern answer. AES works on 16-byte blocks with a non-linear S-box for the substitution and row shifts and column mixing for the diffusion, then mixes in a round key.
  6. Many rounds. AES-128 repeats the round 10 times, each with its own round key derived from the cipher key.
  7. Full diffusion. After 10 rounds, flipping one of the 128 plaintext bits changed between 49 and 77 ciphertext bits in the lesson's run, 63.83 on average, close to the half of all bits that random output would show.

A Caesar shift followed by a columnar transposition makes a classical product cipher. The program measures what it keeps, and how far one changed plaintext letter spreads:

A Caesar shift, then a columnar transposition Python · product/product.py
"""A classical product cipher: a Caesar shift (substitution), then a columnar transposition. Two weak steps make a
cipher that still keeps every letter count, and one changed plaintext letter changes one ciphertext letter."""
from collections import Counter

UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
KEY = "4312567"


def shift(text, k):
    return "".join(UPPER[(UPPER.index(ch) + k) % 26] for ch in text)


def columnar(text, key):
    order = sorted(range(len(key)), key=lambda c: (key[c], c))
    text += "X" * (-len(text) % len(key))
    return "".join(text[r + c] for c in order for r in range(0, len(text), len(key)))


def product(text):
    return columnar(shift(text, 3), KEY)


def index_of_coincidence(text):
    counts = Counter(text)
    return sum(f * (f - 1) for f in counts.values()) / (len(text) * (len(text) - 1))


plain = "ATTACKPOSTPONEDUNTILTWOAMXYZ"
secret = product(plain)
print("plaintext: ", plain)
print("ciphertext:", secret)
print(f"index of coincidence: {index_of_coincidence(plain):.4f} before, {index_of_coincidence(secret):.4f} after")

# Diffusion: change one plaintext letter at a time and count the ciphertext letters that change.
changed = []
for i in range(len(plain)):
    other = plain[:i] + UPPER[(UPPER.index(plain[i]) + 1) % 26] + plain[i + 1:]
    changed.append(sum(a != b for a, b in zip(product(other), secret)))
print(f"one plaintext letter changed: {min(changed)} to {max(changed)} ciphertext letters change, of {len(secret)}")

Output

plaintext:  ATTACKPOSTPONEDUNTILTWOAMXYZ
ciphertext: WWQDDSWPWVXRDRGZFRLANQOBSHWC
index of coincidence: 0.0476 before, 0.0476 after
one plaintext letter changed: 1 to 1 ciphertext letters change, of 28

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 product.py

The index stays exactly as it was, and every changed plaintext letter changed exactly 1 of the 28 ciphertext letters: two classical steps hide nothing that letter counts and pairs cannot recover. AES, as FIPS 197 specifies it, works on blocks of 16 bytes, substitutes with a non-linear table, the S-box, mixes bytes across the block, and repeats the round 10 times for AES-128 with a new round key each time. The program flips each of the 128 bits of one block in turn, on the key and block of NIST SP 800-38A’s example F.1.1:

Diffusion in AES-128: one flipped bit, measured 128 times JavaScript · diffusion-js/aes_diffusion.mjs
// Diffusion in a modern block cipher: AES-128 on one block, the first block of NIST SP 800-38A's example F.1.1.
// ECB here only calls the block cipher once on one 16-byte block, to measure it; never encrypt data with ECB
// (ASVS 5.0, requirement 11.3.1). The key is the published test key of the example, not a secret.
import { createCipheriv } from 'node:crypto';

const KEY = Buffer.from('2b7e151628aed2a6abf7158809cf4f3c', 'hex');
const PLAINTEXT = Buffer.from('6bc1bee22e409f96e93d7e117393172a', 'hex');

function aesBlock(key, block) {
  const cipher = createCipheriv('aes-128-ecb', key, null);
  cipher.setAutoPadding(false);
  return Buffer.concat([cipher.update(block), cipher.final()]);
}

function bitsDifferent(a, b) {
  let n = 0;
  for (let i = 0; i < a.length; i++) {
    for (let x = a[i] ^ b[i]; x; x >>= 1) n += x & 1;
  }
  return n;
}

const ciphertext = aesBlock(KEY, PLAINTEXT);
console.log('ciphertext:', ciphertext.toString('hex'));
console.log('matches SP 800-38A F.1.1:', ciphertext.toString('hex') === '3ad77bb40d7a3660a89ecaf32466ef97');

// Flip each of the 128 plaintext bits in turn and count the ciphertext bits that change.
const changed = [];
for (let bit = 0; bit < 128; bit++) {
  const block = Buffer.from(PLAINTEXT);
  block[bit >> 3] ^= 0x80 >> (bit & 7);
  changed.push(bitsDifferent(aesBlock(KEY, block), ciphertext));
}
const mean = changed.reduce((a, b) => a + b, 0) / changed.length;
console.log(`one plaintext bit flipped: ${Math.min(...changed)} to ${Math.max(...changed)} of 128 ciphertext bits change, ${mean.toFixed(2)} on average`);

Output

ciphertext: 3ad77bb40d7a3660a89ecaf32466ef97
matches SP 800-38A F.1.1: true
one plaintext bit flipped: 49 to 77 of 128 ciphertext bits change, 63.83 on average

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

Between 49 and 77 of the 128 ciphertext bits changed for a single flipped plaintext bit, 63.83 on average: about half, as if the output were random. That is diffusion in full, and it is what the classical round’s 1 in 28 lacked.

Key idea

One substitution and one transposition keep letter counts and pairs that give the plaintext away; many rounds of keyed, non-linear substitution and mixing on large blocks make every output bit depend on every input bit.

Checkpoint

Check: Confusion, diffusion and rounds

  1. One question In the lesson's run of AES-128 on one block, flipping one of the 128 plaintext bits changed how many ciphertext bits on average, to the nearest whole number?

    Type a number.

    Show the answer: One question

    Answer: 64 bits (anything from 63 to 65 counts)

    The average over all 128 flips was 63.83, between 49 and 77 for single flips: about half of all bits, as random output would give. The classical product cipher changed 1 letter of 28 for every changed plaintext letter.

A one-time pad hides everything but the length

The one-time pad: The Vernam cipher with a key that is truly random, at least as long as the message and used only once. It gives perfect secrecy; a pad used twice leaks the XOR of the two plaintexts. is the Vernam cipher of the Handbook’s Definition 1.39, used with a key that is random and never used again: each bit of the message is XORed with a bit of the pad. Its notes record that Vernam described the cipher for telegraphs in 1917, and that Mauborgne saw that a random key, never repeated, made it far stronger. The Handbook’s section 1.13.3 states the strength: a pad used once gives perfect secrecy: Unconditional security for encryption: seeing the ciphertext tells an attacker nothing about the plaintext beyond its length, whatever their computing power. It needs a key at least as long as the message., and a key at least as long as the message is necessary for any symmetric cipher to be unconditionally secure. The first part of the program shows why:

A one-time pad, a pad that fits any message, and a pad used twice Python · otp/one_time_pad.py
"""The one-time pad on bytes: perfect secrecy when the pad is random, as long as the message and used once, and
what leaks when a pad is used twice. The pads are fresh random bytes on every run, so only facts about them print."""
import secrets


def xor(a, b):
    return bytes(x ^ y for x, y in zip(a, b, strict=True))


message = b"ATTACK AT DAWN"
pad = secrets.token_bytes(len(message))  # from the operating system's secure random generator
ciphertext = xor(message, pad)
print("the ciphertext is", len(ciphertext), "bytes, the length of the message, and decrypts back:", xor(ciphertext, pad) == message)

# Perfect secrecy: for any other message of the same length there is a pad that turns this ciphertext into it.
other = b"RETREAT AT TEN"
other_pad = xor(ciphertext, other)
print("another pad, just as likely as the real one, turns the same ciphertext into", xor(ciphertext, other_pad).decode())

# A pad used twice: the two ciphertexts XORed together give the two messages XORed together, with no pad left.
first, second = b"PAY 500 TO ACCOUNT 1111", b"PAY 900 TO ACCOUNT 2222"
pad = secrets.token_bytes(len(first))
c1, c2 = xor(first, pad), xor(second, pad)
leak = xor(c1, c2)
print("c1 XOR c2 equals p1 XOR p2:", leak == xor(first, second))
print("c1 XOR c2:", leak.hex(" "))
print("zero bytes, where the two messages agree:", leak.count(0), "of", len(leak))

Output

the ciphertext is 14 bytes, the length of the message, and decrypts back: True
another pad, just as likely as the real one, turns the same ciphertext into RETREAT AT TEN
c1 XOR c2 equals p1 XOR p2: True
c1 XOR c2: 00 00 00 00 0c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 03 03 03 03
zero bytes, where the two messages agree: 18 of 23

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 one_time_pad.py

For the 14-byte ciphertext of ATTACK AT DAWN there is another pad, just as likely as the real one, that decrypts it to RETREAT AT TEN, and one for every other 14-byte message. An attacker who tries every pad learns only the length. The cost is delivery: the pad must reach the receiver in advance, safely, and be as long as everything it will ever protect. A service that backs up 2 GB a day would need 730 GB of pad a year, delivered before use.

Why random means a secure generator

The pads come from secrets.token_bytes, the operating system’s secure random generator. A pad from a predictable generator is a keystream an attacker can compute, which the randomness module takes apart.

Checkpoint

Check: The one-time pad

  1. One question Which conditions does a one-time pad need for perfect secrecy? Choose every one.

    Choose every answer that is right.

    Show the answer: One question

    Answer:

    • The pad is used only once
    • The pad is truly random
    • The pad is at least as long as the message

    Random, as long as the message and never reused: then every plaintext of that length is equally possible, which is what the lesson's program showed by turning one ciphertext into RETREAT AT TEN with another pad.

Using a keystream twice leaks both messages

The second part of the same program reuses one pad for two payment messages. With c₁ = p₁ ⊕ k and c₂ = p₂ ⊕ k, the pad cancels:

c1⊕︎c2=(p1⊕︎k)⊕︎(p2⊕︎k)=p1⊕︎p2c_1 \oplus c_2 = (p_1 \oplus k) \oplus (p_2 \oplus k) = p_1 \oplus p_2

In words: XORing the two ciphertexts removes the key and leaves the XOR of the two plaintexts, which the Handbook says may permit cryptanalysis because of its redundancy. In the program’s run above it is zero in 18 of 23 bytes, exactly where the messages agree. Real systems do not ship pads; they make 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. from a short key, and AES in counter mode does it by encrypting counter blocks. The rule then moves from the pad to the counter. Step through what a reused counter does in a service of your own:

Interactive · message by message Two messages under one keystream Step through a service that sends two messages with the same counter block, then with a fresh one for each.
Two messages under one keystream, step 6 of 6. Messages so far: Your service to Someone on the path: c1 = p1 XOR keystream; Your service to Someone on the path: c2 = p2 XOR keystream; Someone on the path to Receiver: c1 and c2 delivered; Your service to Someone on the path: c2 under a fresh counter.Your servicefresh counter per messageSomeone on the pathcopies ciphertextsReceiverholds key kc1 = p1 XOR keystreamc2 = p2 XOR keystreamc1 and c2 deliveredc2 under a fresh counterrandom-looking XOR

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

Every step as text
  1. The first message. The service encrypts "PAY 500 TO ACCOUNT 1111 BY 5 PM" with AES-CTR. The keystream comes from the key and the counter block, and is XORed with the message.
  2. The same counter again. After a restart the service starts its counter block from the same value and encrypts the second message with the same keystream.
  3. The keystream cancels. The observer XORs the two ciphertexts. The keystream cancels out and leaves p1 XOR p2, zero wherever the messages agree, with no key needed.
  4. The receiver sees nothing wrong. Both messages decrypt correctly at the receiver. Nothing in normal operation shows that confidentiality is gone, so only a test or a review finds it.
  5. A fresh counter block. The fix gives every message its own counter blocks, never used before under the key, as NIST SP 800-38A requires for every message encrypted under one key.
  6. Nothing cancels. Now c1 XOR c2 still holds p1 XOR p2, but masked by two different keystreams, so it looks random. The lesson's reuse test passes, and the observer learns nothing but the lengths.

NIST SP 800-38A’s section 6.5 states the rule for every message encrypted under one key: all the counter blocks must be distinct, and ASVS 5.0 requirement 11.3.4 says the same of nonces and IVs. The program checks Web Crypto’s AES-CTR against the standard’s example F.5.1, then reuses a counter on purpose and runs this lesson’s reuse test, which flags two ciphertexts whose XOR has the shape of two texts:

AES-CTR: the NIST vector, a reused counter and the test that catches it JavaScript · ctr-js/ctr_reuse.mjs
// AES-CTR, the one-time pad made practical: a keystream from a key and a counter. NIST SP 800-38A's example F.5.1
// first, then a reused counter, the test that catches it and the fix that passes the test. The key and counters of
// part 2 are fresh random values, so the printed results are the test's verdicts.

const hex = (bytes) => Buffer.from(bytes).toString('hex');
const fromHex = (text) => Uint8Array.from(Buffer.from(text, 'hex'));
const encode = (text) => new TextEncoder().encode(text);
const xor = (a, b) => a.map((x, i) => x ^ b[i]);

// 1. The published vector: key, initial counter block and two plaintext blocks of SP 800-38A F.5.1.
const vectorKey = await crypto.subtle.importKey('raw', fromHex('2b7e151628aed2a6abf7158809cf4f3c'), 'AES-CTR', false, ['encrypt']);
const vector = await crypto.subtle.encrypt(
  { name: 'AES-CTR', counter: fromHex('f0f1f2f3f4f5f6f7f8f9fafbfcfdfeff'), length: 64 },
  vectorKey,
  fromHex('6bc1bee22e409f96e93d7e117393172aae2d8a571e03ac9c9eb76fac45af8e51'),
);
console.log('first block:', hex(vector).slice(0, 32));
console.log('SP 800-38A F.5.1, both blocks match:', hex(vector) === '874d6191b620e3261bef6864990db6ce9806f66b7970fdff8617187bb9fffdff');

// 2. A counter used for two messages. The test: XOR the two ciphertexts. Under one keystream that gives the XOR of
// two texts, whose bytes are all below 0x80; under different keystreams about half the bytes are 0x80 or above.
const reuseSuspected = (c1, c2) => xor(c1, c2).filter((b) => b >= 0x80).length / c1.length < 0.1;
const key = await crypto.subtle.generateKey({ name: 'AES-CTR', length: 128 }, false, ['encrypt']);
const ctr = async (counter, data) => new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-CTR', counter, length: 64 }, key, data));
const p1 = encode('PAY 500 TO ACCOUNT 1111 BY 5 PM');
const p2 = encode('PAY 900 TO ACCOUNT 2222 BY 6 PM');
const counter = crypto.getRandomValues(new Uint8Array(16));
const c1 = await ctr(counter, p1);
const c2 = await ctr(counter, p2);
console.log('same counter: c1 XOR c2 equals p1 XOR p2:', hex(xor(c1, c2)) === hex(xor(p1, p2)), '| reuse suspected:', reuseSuspected(c1, c2));
const c2Fresh = await ctr(crypto.getRandomValues(new Uint8Array(16)), p2);
console.log('a fresh counter block for the second message: reuse suspected:', reuseSuspected(c1, c2Fresh));

Output

first block: 874d6191b620e3261bef6864990db6ce
SP 800-38A F.5.1, both blocks match: true
same counter: c1 XOR c2 equals p1 XOR p2: true | reuse suspected: true
a fresh counter block for the second message: reuse suspected: false

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

The test flags the reused counter and passes the fresh one. It looks for the one thing reuse cannot hide: two texts XOR to bytes below 0x80, while two independent keystreams XOR to bytes with the top bit set about half the time.

Where counters repeat in real code

Counter blocks can repeat when a counter kept in memory starts from zero after a restart, when two servers or containers share a key but keep their own counters, or when state is restored from a backup. Persist the counter, give each instance its own key, or use random nonces within the limits of the nonce lesson.

Checkpoint

Check: Keystream reuse

  1. One question Two messages p1 and p2 were encrypted with the same pad or keystream k. What does an observer get from c1 XOR c2?

    Choose one answer.

    Show the answer: One question

    Answer: p1 XOR p2, with the keystream gone

    In the lesson's run, the two payment messages XORed to zero in 18 of 23 bytes, exactly where they agreed, without the pad. That is why SP 800-38A requires counter blocks that never repeat under one key.

A keystream needs authentication too

A pad or a keystream hides a message; it does not protect it. Decryption is the same XOR, so a flipped ciphertext bit becomes a flipped plaintext bit, and nobody notices: the scheme is malleable: Said of a cipher whose ciphertext can be changed so that the plaintext changes in a predictable way, as a flipped bit of a keystream cipher flips the same plaintext bit. Authentication tags prevent it.. This test flips one bit of byte 4 in transit and expects decryption to fail, once for AES-CTR and once for AES-GCM:

The tamper test: AES-CTR fails it, AES-GCM passes it JavaScript · tamper-js/tamper_test.mjs
// A keystream hides a message but does not protect it: flipping a ciphertext bit flips the same plaintext bit. The
// test flips one bit and expects decryption to fail. AES-CTR fails the test; AES-GCM, which adds a 16-byte tag,
// passes it. Keys and nonces are fresh random values on every run.

const encode = (text) => new TextEncoder().encode(text);
const message = encode('PAY 500 TO ACCOUNT 1111 BY 5 PM');

async function tamperTest(name, algorithm, params) {
  const key = await crypto.subtle.generateKey({ name: algorithm, length: 128 }, false, ['encrypt', 'decrypt']);
  const ciphertext = new Uint8Array(await crypto.subtle.encrypt(params, key, message));
  ciphertext[4] ^= 0x01; // one bit of byte 4, the digit 5, flipped in transit
  try {
    const opened = new TextDecoder().decode(await crypto.subtle.decrypt(params, key, ciphertext));
    console.log(`${name}: FAIL, the changed ciphertext decrypts to "${opened}"`);
  } catch (error) {
    console.log(`${name}: pass, decryption refused (${error.name})`);
  }
}

await tamperTest('AES-CTR', 'AES-CTR', { name: 'AES-CTR', counter: crypto.getRandomValues(new Uint8Array(16)), length: 64 });
await tamperTest('AES-GCM', 'AES-GCM', { name: 'AES-GCM', iv: crypto.getRandomValues(new Uint8Array(12)) });

Output

AES-CTR: FAIL, the changed ciphertext decrypts to "PAY 400 TO ACCOUNT 1111 BY 5 PM"
AES-GCM: pass, decryption refused (OperationError)

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

AES-CTR returns PAY 400 instead of PAY 500, without an error; AES-GCM, which adds a 16-byte authentication tag, refuses the changed ciphertext. OWASP’s Cryptographic Storage Cheat Sheet makes authenticated modes such as GCM the first choice, and ASVS 5.0 requirement 11.3.3 asks for encrypted data to be protected against unauthorised modification. Two questions about your data decide between the four ways of XORing data with key material:

Interactive · answer and compare Which stream of key material fits your data? Answer two questions about the data you protect. The table marks what fits and says why the others do not.
Can you deliver, in advance, random key material as long as all the data, and never reuse any of it?
Must the receiver notice a changed or forged message?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Four ways to XOR data with key material, against what each needs and what each protects
Scheme Key to deliver in advance Hides the content Detects a changed message
One-time pad Key material: random bytes, as many as the data Poor as long as all the data ever sent, used once Good perfectly, while no pad is reused Poor a flipped bit decrypts to a flipped bit
XOR with a short key Key material: a few bytes, repeated Good a few bytes Poor a period: each byte column falls to statistics Poor a flipped bit decrypts to a flipped bit
AES-CTR alone Key material: a 128- or 256-bit key and fresh counters Good one 16- or 32-byte key Mixed only while no counter block repeats under the key Poor the tamper test fails: PAY 500 became PAY 400
AES-GCM Key material: a 128- or 256-bit key and fresh nonces Good one 16- or 32-byte key Mixed only while no nonce repeats under the key Good a 16-byte tag: the tamper test passes
How the answers decide
  • “Can you deliver, in advance, random key material as long as all the data, and never reuse any of it?” No, the data is large or keeps coming: rules out One-time pad. Without a pad as long as the data and never reused, the one-time pad has no guarantee, and a reused pad leaks the XOR of two messages.
  • “Must the receiver notice a changed or forged message?” Yes, or a change could do harm: rules out One-time pad, XOR with a short key and AES-CTR alone. These decrypt a changed ciphertext to a changed plaintext without any warning. Only an authenticated cipher refuses it.
  • “Must the receiver notice a changed or forged message?” No, changes do not matter: rules out XOR with a short key and adds a caveat to AES-CTR alone. A repeating key shows its period even when changes do not matter, and CTR still depends on never repeating a counter block under its key.

In short: the one-time pad only where a pad as long as the data can be delivered and destroyed after use; never a short repeating key; and AES-CTR only inside a design that adds authentication. For anything else, an authenticated cipher such as AES-GCM with unique nonces, as the symmetric-encryption module builds.

Checkpoint

Check: Keystreams and tampering

  1. One question In the lesson's tamper test, one bit of byte 4 of an AES-CTR ciphertext was flipped. What did decryption return?

    Choose one answer.

    Show the answer: One question

    Answer: The message with that one character changed, PAY 400 instead of PAY 500, and no error

    Encryption alone hides content but does not protect it. An authenticated cipher such as AES-GCM refuses the changed ciphertext, which is why OWASP's cheat sheet puts authenticated modes first.

Practise: transpositions, then a reuse test

This lab ends the module. Its first step asks for the rail fence and the columnar transposition in both directions, on the syllabus cases, from starter code that wraps the rails like a saw and ignores the key’s digits. Its second step asks for the XOR of a keystream, checked against the published keystream blocks of SP 800-38A, and for this lesson’s reuse test. Sample tests for both run in your browser:

Code lab · 2 steps

Move letters, then catch a reused keystream

Write the rail fence and columnar transposition both ways, then an XOR that refuses mismatched lengths and a test that flags two ciphertexts made with one keystream.

  1. Rail fence and columnar transposition, both ways To do
  2. XOR with a keystream, and a test for a reused one To do

Exercise · Medium · Python, JavaScript

Rail fence and columnar transposition, both ways

Write two functions in transposition.py (JavaScript: transposition.mjs, with the names in camelCase). Both move characters and never change them:

  • rail_fence(text, rails, decrypt=False) writes the characters in a zigzag over the rails, down from rail 0 to the bottom rail and back up, and reads the rails from the top: rail_fence("MEETME", 2) is "MEMETE", the case of GTU 3161606, practical 10, and rail_fence("MEETATNOON", 3) is "MAOETTONEN". With one rail, or as many rails as characters, the text stays as it is. With decrypt true it undoes the encryption.
  • columnar(text, key, filler="XYZ", decrypt=False) writes the text in rows as wide as the key, fills the last row with the letters of filler in order (repeating them if needed), and reads the columns in the order of the key's digits, equal digits from left to right. The practical's key "4312567" turns "ATTACKPOSTPONEDUNTILTWOAM" into "TTNAAPTMTSUOAODWCOIXKNLYPETZ"; decryption gives back "ATTACKPOSTPONEDUNTILTWOAMXYZ", filler included.

The starter's rail fence starts again at rail 0 instead of turning back, which only a test with three or more rails can notice, and its columnar reads the columns from left to right.

Python · Starter code · transposition.py

def rail_fence(text, rails, decrypt=False):
    """The characters in a zigzag over the rails, read rail by rail (decrypt=True undoes it)."""
    pattern = [i % rails for i in range(len(text))]  # slip: starts again at rail 0 instead of turning back
    order = sorted(range(len(text)), key=lambda i: (pattern[i], i))
    if not decrypt:
        return "".join(text[i] for i in order)
    out = [""] * len(text)
    for ch, i in zip(text, order):
        out[i] = ch
    return "".join(out)


def columnar(text, key, filler="XYZ", decrypt=False):
    """Rows as wide as the key, read column by column in the order of the key's digits (decrypt=True undoes it)."""
    width = len(key)
    order = list(range(width))  # slip: the columns are read from left to right, whatever the key says
    if decrypt:
        rows = len(text) // width
        columns = {c: text[k * rows:(k + 1) * rows] for k, c in enumerate(order)}
        return "".join(columns[c][r] for r in range(rows) for c in range(width))
    missing = -len(text) % width
    text += (filler * missing)[:missing]
    return "".join(text[r + c] for c in order for r in range(0, len(text), width))
The sample tests · test_transposition.py
from transposition import columnar, rail_fence


def test_gtu_rail_fence():
    """Meetme on 2 rails is MEMETE, the case of GTU 3161606, practical 10, and back"""
    assert rail_fence("MEETME", 2) == "MEMETE"
    assert rail_fence("MEMETE", 2, decrypt=True) == "MEETME"


def test_three_rails_turn_back():
    """on 3 rails the zigzag turns at the bottom rail: MEETATNOON is MAOETTONEN"""
    assert rail_fence("MEETATNOON", 3) == "MAOETTONEN"
    assert rail_fence("MAOETTONEN", 3, decrypt=True) == "MEETATNOON"
    assert rail_fence("WEAREDISCOVERED", 4) == "WIREDSEEAECVDRO"


def test_rail_fence_edges():
    """one rail, as many rails as characters, and round trips for every rail count"""
    assert rail_fence("ABCDE", 1) == "ABCDE"
    assert rail_fence("ABCDE", 5) == "ABCDE"
    for rails in range(1, 9):
        assert rail_fence(rail_fence("ATTACK AT DAWN!", rails), rails, decrypt=True) == "ATTACK AT DAWN!", rails


def test_gtu_columnar():
    """key 4312567 with filler XYZ, the practical's case, and back"""
    assert columnar("ATTACKPOSTPONEDUNTILTWOAM", "4312567") == "TTNAAPTMTSUOAODWCOIXKNLYPETZ"
    assert columnar("TTNAAPTMTSUOAODWCOIXKNLYPETZ", "4312567", decrypt=True) == "ATTACKPOSTPONEDUNTILTWOAMXYZ"


def test_columnar_keys_and_filling():
    """equal digits read left to right, no filler for full rows, the filler repeats when needed"""
    assert columnar("ABCDEFGH", "3121") == "BFDHCGAE"
    assert columnar("ABCDEFGH", "21") == "BDFHACEG"
    assert columnar("ABCDEFG", "12345678", filler="Q") == "ABCDEFGQ"
    assert columnar("AB", "12345", filler="XY") == "ABXYX"

JavaScript · Starter code · transposition.mjs

/** The characters in a zigzag over the rails, read rail by rail (decrypt = true undoes it). */
export function railFence(text, rails, decrypt = false) {
  const pattern = [...text].map((_, i) => i % rails); // slip: starts again at rail 0 instead of turning back
  const order = [...text].map((_, i) => i).sort((a, b) => pattern[a] - pattern[b] || a - b);
  if (!decrypt) return order.map((i) => text[i]).join('');
  const out = new Array(text.length);
  order.forEach((i, k) => (out[i] = text[k]));
  return out.join('');
}

/** Rows as wide as the key, read column by column in the order of the key's digits (decrypt = true undoes it). */
export function columnar(text, key, filler = 'XYZ', decrypt = false) {
  const width = key.length;
  const order = [...key].map((_, c) => c); // slip: the columns are read from left to right, whatever the key says
  if (decrypt) {
    const rows = text.length / width;
    const columns = {};
    order.forEach((c, k) => (columns[c] = text.slice(k * rows, (k + 1) * rows)));
    let out = '';
    for (let r = 0; r < rows; r++) for (let c = 0; c < width; c++) out += columns[c][r];
    return out;
  }
  const missing = (width - (text.length % width)) % width;
  const padded = text + filler.repeat(missing).slice(0, missing);
  let out = '';
  for (const c of order) for (let r = 0; r < padded.length; r += width) out += padded[r + c];
  return out;
}
The sample tests · transposition.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { columnar, railFence } from './transposition.mjs';

test('Meetme on 2 rails is MEMETE, the case of GTU 3161606, practical 10, and back', () => {
  assert.equal(railFence('MEETME', 2), 'MEMETE');
  assert.equal(railFence('MEMETE', 2, true), 'MEETME');
});

test('on 3 rails the zigzag turns at the bottom rail: MEETATNOON is MAOETTONEN', () => {
  assert.equal(railFence('MEETATNOON', 3), 'MAOETTONEN');
  assert.equal(railFence('MAOETTONEN', 3, true), 'MEETATNOON');
  assert.equal(railFence('WEAREDISCOVERED', 4), 'WIREDSEEAECVDRO');
});

test('one rail, as many rails as characters, and round trips for every rail count', () => {
  assert.equal(railFence('ABCDE', 1), 'ABCDE');
  assert.equal(railFence('ABCDE', 5), 'ABCDE');
  for (let rails = 1; rails <= 8; rails++) assert.equal(railFence(railFence('ATTACK AT DAWN!', rails), rails, true), 'ATTACK AT DAWN!', `${rails}`);
});

test('key 4312567 with filler XYZ, the practical case, and back', () => {
  assert.equal(columnar('ATTACKPOSTPONEDUNTILTWOAM', '4312567'), 'TTNAAPTMTSUOAODWCOIXKNLYPETZ');
  assert.equal(columnar('TTNAAPTMTSUOAODWCOIXKNLYPETZ', '4312567', 'XYZ', true), 'ATTACKPOSTPONEDUNTILTWOAMXYZ');
});

test('equal digits read left to right, no filler for full rows, the filler repeats when needed', () => {
  assert.equal(columnar('ABCDEFGH', '3121'), 'BFDHCGAE');
  assert.equal(columnar('ABCDEFGH', '21'), 'BDFHACEG');
  assert.equal(columnar('ABCDEFG', '12345678', 'Q'), 'ABCDEFGQ');
  assert.equal(columnar('AB', '12345', 'XY'), 'ABXYX');
});
A hint

Work out the rail of each position first: it goes 0, 1, …, rails - 1 and then back down to 0, turning at both ends. Encryption reads the positions rail by rail; decryption writes the ciphertext into the same positions in that order. For the columnar cipher, sort the column numbers by their key digit (sorted(range(len(key)), key=lambda c: (key[c], c))) and read whole columns in that order.

The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 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.

Exercise · Medium · Python, JavaScript

XOR with a keystream, and a test for a reused one

Write two functions in keystream.py (JavaScript: keystream.mjs, with the names in camelCase):

  • xor_bytes(a, b) (JavaScript: xorBytes, on Uint8Arrays) returns the bytes a[i] ^ b[i], and raises ValueError (JavaScript: throws an Error) when the lengths differ, since a keystream shorter than the data must never be stretched or cut silently. The sample tests check it against NIST SP 800-38A's example F.5.1, whose published output blocks are the AES-CTR keystream: plaintext XOR output block is the ciphertext.
  • reuse_suspected(c1, c2) (JavaScript: reuseSuspected) is the review test of this lesson. XOR the first min(len(c1), len(c2)) bytes of the two ciphertexts; return True when there are at least 16 of them and fewer than 10 % have their top bit set (a value of 0x80 or more). Two texts under one keystream XOR to bytes below 0x80; under different keystreams about half the bytes are 0x80 or more. With fewer than 16 bytes it returns False.

The starter code stops at the shorter input without a word, and its test only notices two identical ciphertexts.

Python · Starter code · keystream.py

def xor_bytes(a, b):
    """a[i] XOR b[i]; ValueError when the lengths differ."""
    return bytes(x ^ y for x, y in zip(a, b))  # slip: stops at the shorter input without a word


def reuse_suspected(c1, c2):
    """True when two ciphertexts look like texts under the same keystream."""
    return c1 == c2  # slip: only notices two identical ciphertexts
The sample tests · test_keystream.py
from keystream import reuse_suspected, xor_bytes

# NIST SP 800-38A, example F.5.1 (CTR-AES128.Encrypt): the output blocks are the keystream.
OUTPUT = [bytes.fromhex(h) for h in ["ec8cdf7398607cb0f2d21675ea9ea1e4", "362b7c3c6773516318a077d7fc5073ae",
                                     "6a2cc3787889374fbeb4c81b17ba6c44", "e89c399ff0f198c6d40a31db156cabfe"]]
PLAIN = [bytes.fromhex("6bc1bee22e409f96e93d7e117393172a"), bytes.fromhex("ae2d8a571e03ac9c9eb76fac45af8e51")]
CIPHER = [bytes.fromhex("874d6191b620e3261bef6864990db6ce"), bytes.fromhex("9806f66b7970fdff8617187bb9fffdff")]
M1, M2 = b"PAY 500 TO ACCOUNT 1111 BY 5 PM.", b"PAY 900 TO ACCOUNT 2222 BY 6 PM."


def raises_value_error(call):
    try:
        call()
    except ValueError:
        return True
    return False


def test_sp_800_38a_f51():
    """plaintext XOR output block is the ciphertext of example F.5.1, blocks 1 and 2"""
    for p, o, c in zip(PLAIN, OUTPUT, CIPHER):
        assert xor_bytes(p, o) == c
        assert xor_bytes(c, o) == p


def test_lengths_must_match():
    """a keystream of the wrong length is an error, never cut short"""
    assert raises_value_error(lambda: xor_bytes(b"ABC", b"AB"))
    assert xor_bytes(b"", b"") == b""


def test_one_keystream_is_suspected():
    """two messages under the same keystream are flagged"""
    keystream = OUTPUT[0] + OUTPUT[1]
    assert reuse_suspected(xor_bytes(M1, keystream), xor_bytes(M2, keystream))


def test_two_keystreams_are_not():
    """the same messages under two different keystreams are not flagged"""
    assert not reuse_suspected(xor_bytes(M1, OUTPUT[0] + OUTPUT[1]), xor_bytes(M2, OUTPUT[2] + OUTPUT[3]))


def test_too_little_data():
    """fewer than 16 bytes in common is not enough evidence"""
    assert not reuse_suspected(xor_bytes(M1[:15], OUTPUT[0][:15]), xor_bytes(M2[:15], OUTPUT[0][:15]))
    assert reuse_suspected(xor_bytes(M1[:16], OUTPUT[0]), xor_bytes(M2[:16], OUTPUT[0]))

JavaScript · Starter code · keystream.mjs

/** a[i] XOR b[i] for two Uint8Arrays; throws when the lengths differ. */
export function xorBytes(a, b) {
  return Uint8Array.from({ length: Math.min(a.length, b.length) }, (_, i) => a[i] ^ b[i]); // slip: stops at the shorter input
}

/** True when two ciphertexts look like texts under the same keystream. */
export function reuseSuspected(c1, c2) {
  return c1.length === c2.length && c1.every((x, i) => x === c2[i]); // slip: only notices two identical ciphertexts
}
The sample tests · keystream.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { reuseSuspected, xorBytes } from './keystream.mjs';

const fromHex = (h) => Uint8Array.from(h.match(/../g).map((x) => parseInt(x, 16)));
const ascii = (text) => Uint8Array.from(text, (c) => c.charCodeAt(0));
const concat = (a, b) => Uint8Array.from([...a, ...b]);
// NIST SP 800-38A, example F.5.1 (CTR-AES128.Encrypt): the output blocks are the keystream.
const OUTPUT = ['ec8cdf7398607cb0f2d21675ea9ea1e4', '362b7c3c6773516318a077d7fc5073ae', '6a2cc3787889374fbeb4c81b17ba6c44', 'e89c399ff0f198c6d40a31db156cabfe'].map(fromHex);
const PLAIN = ['6bc1bee22e409f96e93d7e117393172a', 'ae2d8a571e03ac9c9eb76fac45af8e51'].map(fromHex);
const CIPHER = ['874d6191b620e3261bef6864990db6ce', '9806f66b7970fdff8617187bb9fffdff'].map(fromHex);
const M1 = ascii('PAY 500 TO ACCOUNT 1111 BY 5 PM.');
const M2 = ascii('PAY 900 TO ACCOUNT 2222 BY 6 PM.');

test('plaintext XOR output block is the ciphertext of example F.5.1, blocks 1 and 2', () => {
  for (let i = 0; i < 2; i++) {
    assert.deepEqual(xorBytes(PLAIN[i], OUTPUT[i]), CIPHER[i]);
    assert.deepEqual(xorBytes(CIPHER[i], OUTPUT[i]), PLAIN[i]);
  }
});

test('a keystream of the wrong length is an error, never cut short', () => {
  assert.throws(() => xorBytes(ascii('ABC'), ascii('AB')));
  assert.deepEqual(xorBytes(new Uint8Array(0), new Uint8Array(0)), new Uint8Array(0));
});

test('two messages under the same keystream are flagged', () => {
  const keystream = concat(OUTPUT[0], OUTPUT[1]);
  assert.ok(reuseSuspected(xorBytes(M1, keystream), xorBytes(M2, keystream)));
});

test('the same messages under two different keystreams are not flagged', () => {
  assert.ok(!reuseSuspected(xorBytes(M1, concat(OUTPUT[0], OUTPUT[1])), xorBytes(M2, concat(OUTPUT[2], OUTPUT[3]))));
});

test('fewer than 16 bytes in common is not enough evidence', () => {
  assert.ok(!reuseSuspected(xorBytes(M1.slice(0, 15), OUTPUT[0].slice(0, 15)), xorBytes(M2.slice(0, 15), OUTPUT[0].slice(0, 15))));
  assert.ok(reuseSuspected(xorBytes(M1.slice(0, 16), OUTPUT[0]), xorBytes(M2.slice(0, 16), OUTPUT[0])));
});
A hint

bytes(x ^ y for x, y in zip(a, b, strict=True)) raises ValueError by itself when the lengths differ (JavaScript: compare the lengths first, then a.map((x, i) => x ^ b[i])). For the test, count the bytes of the XOR that are >= 0x80 and compare the count with a tenth of their number.

The sample tests run on this device, in your browser (Pyodide, QuickJS): nothing is sent to mysmartcopilot.com. The first run of each language downloads it: Python (about 13.5 MB) or JavaScript (about 0.6 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.

Text to Binary & Hex Converter See the bytes of your own two messages in binary, and XOR them by hand to watch equal characters cancel.

Then the self-check: it starts with the idea you have answered least well, explains every choice as soon as you make it, and returns to anything you missed:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 12 What does a transposition cipher change, and what does it keep?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: It changes the positions of the letters and keeps every letter

    A transposition is a permutation of positions, given by a key such as a rail count or a column order. Every letter of the plaintext is in the ciphertext, somewhere else.

  2. Question 2 of 12 The Handbook's Example 7.43 encrypts CAESAR with the permutation e = (6 4 1 3 5 2), taking position 6 first, then 4, 1, 3, 5 and 2. What is the ciphertext?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: RSCEAA

    The ciphertext lists the plaintext's characters in the order the key gives: R (6), S (4), C (1), E (3), A (5), A (2). The lesson's program prints RSCEAA.

  3. Question 3 of 12 Why does a rail fence ciphertext have exactly the index of coincidence of its plaintext?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: It contains the same letters, so every letter count is the same

    The Handbook's Note 7.47 says transpositions are easily recognised because they keep the frequency of each character. The index and a chi-squared score against English both show it.

  4. Question 4 of 12 The lesson tried every rail count from 2 to 10 on its rail fence ciphertext. How did it pick 5?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: Only the 5-rail decryption had 26.7 % of its adjacent pairs among the Handbook's 15 common English pairs; the others stayed at 13.6 % or below

    A transposition hides pairs, not letters, so pairs reveal its key. The Handbook notes that the top 15 English digrams make up about 27 % of all pairs, and the passage itself scored 26.7 %.

  5. Question 5 of 12 According to the Handbook's Remark 1.36, which step of a round adds confusion, and which adds diffusion?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: Substitution adds confusion, transposition adds diffusion

    Most modern block ciphers apply many rounds, each with substitution for confusion and transposition or mixing for diffusion, an idea the Handbook credits to Shannon.

  6. Question 6 of 12 The lesson's product of a Caesar shift and a columnar transposition kept the index of coincidence exactly and changed 1 of 28 letters for each changed plaintext letter. What do real block ciphers do differently?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: They substitute whole bytes through a fixed non-linear S-box, mix every byte into many others and add a new round key, over many rounds

    One classical round keeps letter counts and moves each change to one place. Real ciphers make every output bit depend on every input bit and key bit, which is what the 63.83 of 128 changed bits shows.

  7. Question 7 of 12 A team wants one-time pad encryption for 2 GB of backups a day for a year. How many gigabytes of pad must both sides hold, delivered in advance?

    Type a number.

    Show the answer: Question 7 of 12

    Answer: 730 GB (anything from 729 to 731 counts)

    The pad must be as long as everything it protects and never reused: 2 GB a day for 365 days is 730 GB of random bytes, delivered safely before use. That delivery problem is why real systems use a short key and a stream cipher instead.

  8. Question 8 of 12 The lesson's program found a second pad that turns the ciphertext of ATTACK AT DAWN into RETREAT AT TEN. Why does that show perfect secrecy?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Every plaintext of the same length comes from some pad, and every pad is equally likely, so the ciphertext tells nothing but the length

    Perfect secrecy means the ciphertext gives no information about the plaintext beyond its length. Searching all pads cannot help, because every candidate message has its own pad.

  9. Question 9 of 12 What does NIST SP 800-38A require of the counter blocks of AES-CTR?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Every counter block must be distinct across all messages encrypted under a given key

    A repeated counter block under one key repeats keystream, which gives an observer p1 XOR p2. ASVS 5.0 requirement 11.3.4 states the same rule for nonces and IVs in general.

  10. Question 10 of 12 A service keeps its AES-CTR counter in memory and starts it from zero after every restart, with the same key. What do you change first?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Make every message's counter blocks unique under the key, for example by persisting the counter or deriving a fresh key per process, and add the reuse test to the suite

    Restarts, clones and backups reset in-memory counters, so counters must come from somewhere that survives them, or the key must change. Moving to an authenticated cipher such as AES-GCM adds tamper protection, but its nonces must be unique too.

  11. Question 11 of 12 Why is a one-time pad or a bare keystream called malleable?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: Flipping a ciphertext bit flips the same plaintext bit, so a change survives decryption unnoticed

    Secrecy and integrity are different properties. A keystream gives the first, and an authentication tag gives the second, as the lesson's tamper test shows.

  12. Question 12 of 12 You must encrypt payment instructions between two services that share a key, and changed messages must be noticed. What do you use?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: An authenticated cipher such as AES-GCM, with a unique nonce for every message under the key

    The table of the last section rules out the one-time pad when the key cannot be delivered, and everything without a tag when changes must be noticed. That leaves an authenticated cipher used with unique nonces.

Interview questions

Warm-up (fresher to mid level): what makes a one-time pad unconditionally secure, and why is it rarely used?

A one-time pad XORs the message with a key that is truly random, at least as long as the message, kept secret and used only once. Then every plaintext of the same length is equally possible: for any candidate message there is a pad that decrypts the ciphertext to it, so even an attacker with unlimited computing learns nothing but the length. The Handbook of Applied Cryptography states that a key at least as long as the message is necessary for this. The cost is the key: it must be delivered safely in advance and be as large as all the data, 730 GB for a year of 2 GB daily backups, and it fails badly when reused, because two ciphertexts XOR to the XOR of their plaintexts. It also detects no tampering. So real systems use a short key with a stream or authenticated cipher instead.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to staff

    A service encrypts records with AES-CTR and keeps its counter in memory, so after every restart the counter starts again from zero under the same key. What leaks, how do you find the affected records in data you already hold, and how do you fix it?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to senior

    Explain confusion and diffusion, and show with numbers why a classical product cipher of one substitution and one transposition fails where AES succeeds.

    Model answer, Pro. First step and follow-ups

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 a transposition cipher keep, and how do the rail fence and columnar ciphers work?

    It moves letters and changes none, so it keeps every letter count, the index of coincidence (about 0.069) and a low chi-squared score as it stands. The rail fence writes the text in a zigzag over its rails, turning at both ends. Columnar transposition writes rows as wide as the key and reads whole columns in the order of the key's digits.

  2. How do you tell a transposition from a substitution without a key?

    Read two numbers. An index of coincidence near 0.069 with a low chi-squared score as it stands (51.5 to 57.0 in the lesson) points to a transposition; the same index with a high score (1,207 to 4,050) points to a substitution. A transposition also breaks common letter pairs: only the right key restores them, which gives the key away.

  3. What do confusion and diffusion mean, and how well does one classical round do?

    Confusion is a complicated relation between key and ciphertext, which substitution adds. Diffusion is the plaintext's redundancy spread over the ciphertext, which transposition and mixing add. A substitution followed by a transposition is a round of a product cipher. One classical round changed only 1 of 28 ciphertext letters per changed plaintext letter.

  4. How many ciphertext bits change when one plaintext bit of an AES-128 block is flipped?

    Between 49 and 77 of the 128 bits, 63.83 on average: about half, as if the output were random. AES substitutes with a non-linear S-box, mixes bytes across the 16-byte block and repeats the round 10 times with a new round key each time. That is the diffusion a single classical round lacks.

  5. What makes a one-time pad unconditionally secure, and what does it cost?

    A truly random key at least as long as the message, kept secret and used once. Then every plaintext of that length is equally possible (a pad exists that turns the ciphertext of ATTACK AT DAWN into RETREAT AT TEN), so an attacker learns only the length. The cost is delivery: backing up 2 GB a day would need 730 GB of pad a year.

  6. Why does using a pad or a keystream twice leak both messages?

    c1 ⊕ c2 = (p1 ⊕ k) ⊕ (p2 ⊕ k) = p1 ⊕ p2: the key cancels and leaves the XOR of the two plaintexts, which keeps their redundancy (zero in 18 of 23 bytes where two payment messages agreed). NIST SP 800-38A section 6.5 requires every counter block under one key to be distinct.

  7. Where do counter blocks repeat in real code?

    When a counter kept in memory starts from zero after a restart, when two servers or containers share a key but keep their own counters, and when state is restored from a backup. Persist the counter, give each instance its own key, or use random nonces within their limits.

  8. What does malleable mean, and which mode shows it?

    Decryption is the same XOR as encryption, so a flipped ciphertext bit becomes a flipped plaintext bit with no error: AES-CTR returned PAY 400 instead of PAY 500. AES-GCM refused the changed ciphertext because of its 16-byte tag. A keystream hides content but does not protect it, so use an authenticated cipher with unique nonces.

Key takeaways

  • A transposition moves letters and changes none: the rail fence zigzags over its rails, and a columnar cipher reads columns in the order of its key’s digits.
  • Transposition keeps the index of coincidence and a low chi-squared score as it stands but breaks letter pairs, which both identifies it and finds its key.
  • A product cipher combines substitution for confusion with transposition for diffusion; one classical round still leaks, while AES’s 10 rounds change about 64 of 128 bits for one flipped input bit.
  • A one-time pad gives perfect secrecy only with a random pad as long as the message, used once, and reusing a pad or a counter leaks the XOR of two plaintexts.
  • A keystream hides content but lets changes through, so data you own needs an authenticated cipher with unique nonces, with tests for reuse and for tampering.

Terms in this lesson

columnar transposition
A transposition that writes the text in rows as wide as the key, fills the last row, and reads whole columns in the order of the key's digits or letters.
confusion
The property, added by a cipher's substitution steps, that makes the relation between the key and the ciphertext as complex as possible. Shannon named it together with diffusion.
diffusion
The property, added by a cipher's transposition and mixing steps, that spreads the plaintext's structure over the whole ciphertext, so that one changed input bit changes about half the output bits.
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.
malleable
Said of a cipher whose ciphertext can be changed so that the plaintext changes in a predictable way, as a flipped bit of a keystream cipher flips the same plaintext bit. Authentication tags prevent it.
one-time pad
The Vernam cipher with a key that is truly random, at least as long as the message and used only once. It gives perfect secrecy; a pad used twice leaks the XOR of the two plaintexts.
perfect secrecy
Unconditional security for encryption: seeing the ciphertext tells an attacker nothing about the plaintext beyond its length, whatever their computing power. It needs a key at least as long as the message.
product cipher
A cipher built by composing simpler steps, typically substitution and transposition, into rounds that are repeated. DES and AES are product ciphers with many rounds.
rail fence cipher
A transposition that writes the text in a zigzag over a number of rails, down to the bottom rail and back up, then reads the rails in order. Its key, the number of rails, is easy to search.
transposition cipher
A cipher that moves the characters of the plaintext to new positions and changes none of them, so the ciphertext keeps every letter count of the plaintext.

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.