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.
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:
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
- just taken by the loop
- changed in this step
- passed by the loop
- read next
- where the loop stopped
Printed so far
Nothing printed yetMAOETTONEN
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.
Change the program and run it here. Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Every step as text
- Start. No line has run yet, so there are no variables. Python starts with line 1.
- Line 1.
text = "MEETATNOON"setstextto'MEETATNOON'. - Line 2.
n = 3setsnto3. - Line 3.
rails = [""] * nsetsrailsto['', '', '']. - Line 4.
r, step = 0, 1setsrto0and setsstepto1. - Line 5.
for ch in text:takes the character at position 0 oftext:chis'M'. - Line 6.
rails[r] += chchangesrails: it is now['M', '', '']. Key point M goes on rail 0. Every letter keeps its own value; only its position will change. - Line 7.
r == 0is true (ris0), so the block underif r == 0:runs. - Line 8.
step = 1stepkeeps its value,1. - Line 11.
r += stepchangesrfrom0to1. - Line 5.
for ch in text:takes the character at position 1 oftext:chis'E'. - Line 6.
rails[r] += chchangesrails: it is now['M', 'E', '']. - Line 7.
r == 0is false (ris1), so Python tests the next condition. - Line 9.
r == n - 1is false (ris1andnis3), so Python skips the block underelif r == n - 1:. - Line 11.
r += stepchangesrfrom1to2. - Line 5.
for ch in text:takes the character at position 2 oftext:chis'E'. - Line 6.
rails[r] += chchangesrails: it is now['M', 'E', 'E']. - Line 7.
r == 0is false (ris2), so Python tests the next condition. - Line 9.
r == n - 1is true (ris2andnis3), so the block underelif r == n - 1:runs. - Line 10.
step = -1setsstepto-1(it was1). The third letter reached the bottom rail, so step turns to -1 and the zigzag climbs back up. - Line 11.
r += stepchangesrfrom2to1. - Line 5.
for ch in text:takes the character at position 3 oftext:chis'T'. - Line 6.
rails[r] += chchangesrails: it is now['M', 'ET', 'E']. - Line 7.
r == 0is false (ris1), so Python tests the next condition. - Line 9.
r == n - 1is false (ris1andnis3), so Python skips the block underelif r == n - 1:. - Line 11.
r += stepchangesrfrom1to0. - Line 5.
for ch in text:takes the character at position 4 oftext:chis'A'. - Line 6.
rails[r] += chchangesrails: it is now['MA', 'ET', 'E']. - Line 7.
r == 0is true (ris0), so the block underif r == 0:runs. - Line 8.
step = 1setsstepto1(it was-1). - Line 11.
r += stepchangesrfrom0to1. - Line 5.
for ch in text:takes the character at position 5 oftext:chis'T'. - Line 6.
rails[r] += chchangesrails: it is now['MA', 'ETT', 'E']. - Line 7.
r == 0is false (ris1), so Python tests the next condition. - Line 9.
r == n - 1is false (ris1andnis3), so Python skips the block underelif r == n - 1:. - Line 11.
r += stepchangesrfrom1to2. - Line 5.
for ch in text:takes the character at position 6 oftext:chis'N'. - Line 6.
rails[r] += chchangesrails: it is now['MA', 'ETT', 'EN']. - Line 7.
r == 0is false (ris2), so Python tests the next condition. - Line 9.
r == n - 1is true (ris2andnis3), so the block underelif r == n - 1:runs. - Line 10.
step = -1setsstepto-1(it was1). - Line 11.
r += stepchangesrfrom2to1. - Line 5.
for ch in text:takes the character at position 7 oftext:chis'O'. - Line 6.
rails[r] += chchangesrails: it is now['MA', 'ETTO', 'EN']. - Line 7.
r == 0is false (ris1), so Python tests the next condition. - Line 9.
r == n - 1is false (ris1andnis3), so Python skips the block underelif r == n - 1:. - Line 11.
r += stepchangesrfrom1to0. - Line 5.
for ch in text:takes the character at position 8 oftext:chis'O'. - Line 6.
rails[r] += chchangesrails: it is now['MAO', 'ETTO', 'EN']. - Line 7.
r == 0is true (ris0), so the block underif r == 0:runs. - Line 8.
step = 1setsstepto1(it was-1). - Line 11.
r += stepchangesrfrom0to1. - Line 5.
for ch in text:takes the character at position 9 oftext:chis'N'. - Line 6.
rails[r] += chchangesrails: it is now['MAO', 'ETTON', 'EN']. - Line 7.
r == 0is false (ris1), so Python tests the next condition. - Line 9.
r == n - 1is false (ris1andnis3), so Python skips the block underelif r == n - 1:. - Line 11.
r += stepchangesrfrom1to2. - Line 5.
for ch in text:finds no character at position 10 oftext(it has 10 characters), so the loop ends. - Line 12.
print("".join(rails))printsMAOETTONEN. 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 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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:
// 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
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
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
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:
"""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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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
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:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- Many rounds. AES-128 repeats the round 10 times, each with its own round key derived from the cipher key.
- 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 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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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 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
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:
"""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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
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
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:
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 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
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:
// 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:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| 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
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.
- Rail fence and columnar transposition, both ways To do
- 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, andrail_fence("MEETATNOON", 3)is"MAOETTONEN". With one rail, or as many rails as characters, the text stays as it is. Withdecrypttrue 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 offillerin 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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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, onUint8Arrays) returns the bytesa[i] ^ b[i], and raisesValueError(JavaScript: throws anError) 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 firstmin(len(c1), len(c2))bytes of the two ciphertexts; returnTruewhen 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 returnsFalse.
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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
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.
-
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?
-
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.
Flashcards · 8 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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.
-
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
- Handbook of Applied Cryptography, chapter 1 (sections 1.5.2 to 1.5.4: Definition 1.32, Example 1.35, Remark 1.36, Definition 1.39; section 1.13.3) (CRC Press (authors' copy, Menezes, van Oorschot and Vanstone))
- Handbook of Applied Cryptography, chapter 7 (section 7.3.1: Example 7.43, Note 7.47; section 7.3.5 and Figure 7.6; notes in section 7.8) (CRC Press (authors' copy, Menezes, van Oorschot and Vanstone))
- Cryptography and Network Security, subject 3161606 (practical 10) (Gujarat Technological University)
- NIST SP 800-38A: Recommendation for Block Cipher Modes of Operation (section 6.5; Appendix F.1.1 and F.5.1) (National Institute of Standards and Technology (NIST))
- NIST SP 800-38D: Recommendation for Block Cipher Modes of Operation, GCM (sections 8.3 and 9.1; Appendix A) (National Institute of Standards and Technology (NIST))
- FIPS 197: Advanced Encryption Standard (AES) (rounds, SubBytes, ShiftRows, MixColumns) (National Institute of Standards and Technology (NIST))
- OWASP Cryptographic Storage Cheat Sheet (cipher modes: authenticated modes first) (OWASP Foundation)
- OWASP Application Security Verification Standard 5.0, V11 Cryptography (requirements 11.3.1, 11.3.3 and 11.3.4) (OWASP Foundation)
- Node.js 24 documentation: Web Crypto API (AES-CTR, AES-GCM, generateKey) (OpenJS Foundation (Node.js))
- Node.js 24 documentation: crypto.createCipheriv() (OpenJS Foundation (Node.js))
- Python 3.14 documentation: secrets, generate secure random numbers (Python Software Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress