Applied Cryptography for Developers Module 2 – Classical ciphers and cryptanalysis
Playfair, Vigenère and Hill ciphers
Vigenère, autokey, Playfair and Hill ciphers in code, a Vigenère key found with Kasiski and the index of coincidence, and a Hill key from known text.
What you will learn
- Encrypt and decrypt with Vigenère, autokey, Playfair and Hill ciphers, reproducing the syllabus's Playfair and Hill examples
- Estimate a Vigenère key length from repeat distances and column indices of coincidence
- Recover a Hill key matrix from known plaintext with a modular matrix inverse
- Explain why periodic, digraph and linear ciphers fall to structure that modern ciphers are built to hide
- Recognise repeating-key XOR in code you review as a Vigenère cipher on bytes
Before you start
What you’ll learn · Before you start
What you will learn
- Encrypt and decrypt with Vigenère, autokey, Playfair and Hill ciphers, reproducing the syllabus's Playfair and Hill examples
- Estimate a Vigenère key length from repeat distances and column indices of coincidence
- Recover a Hill key matrix from known plaintext with a modular matrix inverse
- Explain why periodic, digraph and linear ciphers fall to structure that modern ciphers are built to hide
- Recognise repeating-key XOR in code you review as a Vigenère cipher on bytes
Before you start
On this page
Under the 7-letter key MONSOON, a Vigenère cipher lowers the index of coincidence of this module’s passage from 0.0692 to 0.0483, close enough to random letters that counting letters no longer finds the key. Yet one measurement gives it away: cut the ciphertext into 7 columns and every column is English again, with a mean index of 0.0700.
This lesson works through the classical answers to frequency analysis and the methods that answered them in turn: Vigenère and autokey ciphers, which change the shift at every position; Playfair, which substitutes pairs of letters; and Hill, which multiplies blocks by a matrix. You find a key length the way Kasiski and Friedman did, solve a Hill key from a known message start, and measure what each cipher hides and what it leaves behind.
Each position gets its own shift
MEETATTHEGATE under the key STORM becomes EXSKMLMVVSSMS, and its four Es become X, S, V and S: one plaintext letter, three different ciphertext letters. A polyalphabetic cipher: A cipher that uses several substitution alphabets in turn, so that one plaintext letter can become different ciphertext letters. The Vigenère and autokey ciphers and the rotor machines are polyalphabetic. uses several substitution alphabets in turn, and the simplest, the Vigenère cipher: The repeating-key polyalphabetic cipher: letter i is shifted by key letter i mod t, so it is t Caesar ciphers interleaved. The name is a historical misattribution; Vigenère himself described an autokey. of the Handbook’s Definition 7.53, shifts letter i by key letter i mod t, where t is the key’s length: t Caesar ciphers, interleaved. Step through it:
Program · vigenere-trace/vigenere_trace.py
text = "MEETATTHEGATE"
key = "STORM"
out = ""
for i, ch in enumerate(text):
k = ord(key[i % len(key)]) - 65
p = ord(ch) - 65
out += chr((p + k) % 26 + 65)
print(out) 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 yetEXSKMLMVVSSMS
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 58 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 = "MEETATTHEGATE"setstextto'MEETATTHEGATE'. - Line 2.
key = "STORM"setskeyto'STORM'. - Line 3.
out = ""setsoutto''. - Line 4.
for i, ch in enumerate(text):takes the item at position 0 oftext, numbered 0:iis0andchis'M'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto18. Key point Position 0 takes the key letter S, so M moves 18 places and wraps round to E. - Line 6.
p = ord(ch) - 65setspto12. - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom''to'E'. - Line 4.
for i, ch in enumerate(text):takes the item at position 1 oftext, numbered 1:iis1andchis'E'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto19(it was18). - Line 6.
p = ord(ch) - 65setspto4(it was12). - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'E'to'EX'. The first E moves by T, 19 places, to X. The next E, at position 2, takes O instead and becomes S. - Line 4.
for i, ch in enumerate(text):takes the item at position 2 oftext, numbered 2:iis2andchis'E'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto14(it was19). - Line 6.
p = ord(ch) - 65pkeeps its value,4. - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EX'to'EXS'. - Line 4.
for i, ch in enumerate(text):takes the item at position 3 oftext, numbered 3:iis3andchis'T'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto17(it was14). - Line 6.
p = ord(ch) - 65setspto19(it was4). - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EXS'to'EXSK'. - Line 4.
for i, ch in enumerate(text):takes the item at position 4 oftext, numbered 4:iis4andchis'A'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto12(it was17). - Line 6.
p = ord(ch) - 65setspto0(it was19). - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EXSK'to'EXSKM'. - Line 4.
for i, ch in enumerate(text):takes the item at position 5 oftext, numbered 5:iis5andchis'T'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto18(it was12). Key point Position 5 wraps back to the first key letter, S. Every 5th letter shares one shift, which is the weakness the next section uses. - Line 6.
p = ord(ch) - 65setspto19(it was0). - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EXSKM'to'EXSKML'. - Line 4.
for i, ch in enumerate(text):takes the item at position 6 oftext, numbered 6:iis6andchis'T'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto19(it was18). - Line 6.
p = ord(ch) - 65pkeeps its value,19. - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EXSKML'to'EXSKMLM'. - Line 4.
for i, ch in enumerate(text):takes the item at position 7 oftext, numbered 7:iis7andchis'H'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto14(it was19). - Line 6.
p = ord(ch) - 65setspto7(it was19). - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EXSKMLM'to'EXSKMLMV'. - Line 4.
for i, ch in enumerate(text):takes the item at position 8 oftext, numbered 8:iis8andchis'E'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto17(it was14). - Line 6.
p = ord(ch) - 65setspto4(it was7). - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EXSKMLMV'to'EXSKMLMVV'. - Line 4.
for i, ch in enumerate(text):takes the item at position 9 oftext, numbered 9:iis9andchis'G'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto12(it was17). - Line 6.
p = ord(ch) - 65setspto6(it was4). - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EXSKMLMVV'to'EXSKMLMVVS'. - Line 4.
for i, ch in enumerate(text):takes the item at position 10 oftext, numbered 10:iis10andchis'A'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto18(it was12). - Line 6.
p = ord(ch) - 65setspto0(it was6). - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EXSKMLMVVS'to'EXSKMLMVVSS'. - Line 4.
for i, ch in enumerate(text):takes the item at position 11 oftext, numbered 11:iis11andchis'T'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto19(it was18). - Line 6.
p = ord(ch) - 65setspto19(it was0). - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EXSKMLMVVSS'to'EXSKMLMVVSSM'. - Line 4.
for i, ch in enumerate(text):takes the item at position 12 oftext, numbered 12:iis12andchis'E'. - Line 5.
k = ord(key[i % len(key)]) - 65setskto14(it was19). - Line 6.
p = ord(ch) - 65setspto4(it was19). - Line 7.
out += chr((p + k) % 26 + 65)changesoutfrom'EXSKMLMVVSSM'to'EXSKMLMVVSSMS'. - Line 4.
for i, ch in enumerate(text):finds no character at position 13 oftext(it has 13 characters), so the loop ends. - Line 8.
print(out)printsEXSKMLMVVSSMS. That was the last line, so the program ends. MEETATTHEGATE became EXSKMLMVVSSMS. Its four Es became X, S, V and S: the Es at positions 2 and 12 are 10 apart, two turns of the key, so both took O.
The autokey cipher: A cipher whose key is a short primer followed by the plaintext itself, so the key never repeats. It has no period to find, but its key has the statistics of the language. of Example 7.61 removes the repetition: a primer keys the first letters, then each plaintext letter keys a later one, so the key never repeats. Both run below, with the passage under MONSOON measured for its index of coincidence:
"""The Vigenere cipher and its autokey variant, and what a repeating key does to the index of coincidence."""
from collections import Counter
UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
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."
)
def letters(text):
return [UPPER.index(ch) for ch in text.upper() if ch in UPPER]
def vigenere(text, key, sign=1):
"""Letter i moves by key letter i mod len(key); sign=-1 decrypts."""
k = letters(key)
return "".join(UPPER[(p + sign * k[i % len(k)]) % 26] for i, p in enumerate(letters(text)))
def autokey_encrypt(text, primer):
"""The primer shifts the first letters; after it, each plaintext letter shifts a later one."""
p = letters(text)
k = letters(primer) + p
return "".join(UPPER[(x + k[i]) % 26] for i, x in enumerate(p))
def autokey_decrypt(text, primer):
k = letters(primer)
out = []
for i, c in enumerate(letters(text)):
out.append((c - k[i]) % 26)
k.append(out[-1]) # each recovered letter becomes key material
return "".join(UPPER[x] for x in out)
def index_of_coincidence(text):
counts = Counter(letters(text))
n = sum(counts.values())
return sum(f * (f - 1) for f in counts.values()) / (n * (n - 1))
secret = vigenere("MEETATTHEGATE", "STORM")
print("Vigenere, key STORM: ", secret, "->", vigenere(secret, "STORM", -1))
secret = autokey_encrypt("MEETATTHEGATE", "THUNDER")
print("autokey, primer THUNDER:", secret, "->", autokey_decrypt(secret, "THUNDER"))
ciphertext = vigenere(PASSAGE, "MONSOON")
print(f"index of coincidence, the passage: {index_of_coincidence(PASSAGE):.4f}")
print(f"index of coincidence, Vigenere MONSOON: {index_of_coincidence(ciphertext):.4f}")
print(f"index of coincidence, autokey THUNDER: {index_of_coincidence(autokey_encrypt(PASSAGE, 'THUNDER')):.4f}")
print("first 35 letters under MONSOON:", ciphertext[:35]) Output
Vigenere, key STORM: EXSKMLMVVSSMS -> MEETATTHEGATE autokey, primer THUNDER: FLYGDXKTIKTTX -> MEETATTHEGATE index of coincidence, the passage: 0.0692 index of coincidence, Vigenere MONSOON: 0.0483 index of coincidence, autokey THUNDER: 0.0394 first 35 letters under MONSOON: IVRFHVRRWEKHVRMJLJOWADSNUVSQFVRUCOF
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 vigenere.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 repeating key pulls the index from 0.0692 to 0.0483, and the autokey to 0.0394, almost the 0.0385 of random letters. The autokey’s key is English text, though, and the Handbook notes that such running keys still leave statistical imbalances to exploit. The names carry a surprise too: the Handbook’s notes say the simple repeating-key cipher is wrongly attributed to Vigenère, whose 1586 book described an autokey. Each design in this lesson was an answer to a method of attack, and each was answered in turn:
- c. 1467
Alberti's cipher disk
The Florentine architect Alberti built a cipher disk of two wheels and turned it after every few words, so one plaintext letter could become different ciphertext letters. The Handbook dates the invention of polyalphabetic ciphers to him.
- 1553
Belaso's easily changed key
Belaso proposed choosing the shifts with an easily changed key, remembered as a key phrase. His idea is close to the repeating-key cipher that now carries Vigenère's name.
- 1586
Vigenère's autokey
Vigenère's book Traicté des Chiffres described an autokey cipher, where a single priming letter starts the key and the plaintext itself continues it. The far weaker repeating-key cipher is attributed to him in error.
- c. 1854
Playfair, a cipher on pairs digraphs
Invented by Wheatstone and popularised by Playfair, the cipher replaced pairs of letters using a 5 by 5 square, and served as a British field cipher.
- 1863
Kasiski's method attack
Kasiski published how repeated ciphertext segments reveal the length of a repeating key, which turns a Vigenère cipher into several Caesar ciphers.
- 1914
Playfair solved attack
Mauborgne is credited with the first known solution of the Playfair cipher, about sixty years after it was popularised.
- 1920
The index of coincidence attack
Friedman published his work on the index of coincidence, a single number that tells one alphabet from many and estimates a key's period without any repeated segments.
- 1929
Hill's matrix cipher
Hill proposed enciphering blocks of letters with a matrix, a practical way to substitute several letters at once. Because it is linear, known plaintext gives its key away.
- 1949
Shannon's secrecy systems
Shannon's paper on secrecy systems described confusion and diffusion and the product of simple ciphers, the ideas behind the rounds of modern block ciphers, taken up in the next lesson.
The timeline has 9 events, from c. 1467 to 1949.
Letting spaces take key letters
Wrong: restarting the key at every word, or letting a space or a comma use up a key letter. Then the same message encrypts differently depending on its punctuation, and a receiver who drops spaces first cannot decrypt it. Right: number only the letters, 0, 1, 2 and so on, and take key letter i mod t for letter i, as both programs do.
Checkpoint
Check: Vigenère and autokey
A repeating key shows through as a period
A repeating key turns one hard problem into t easy ones, once t is known. The Handbook’s section 7.3.5 gives two ways to find it. Kasiski's method: Finding the key length of a repeating-key cipher from the distances between repeated ciphertext segments, which tend to be multiples of the key length. Kasiski published it in 1863. looks for repeated segments in the ciphertext: the same plaintext under the same part of the key gives the same ciphertext, so repeats tend to sit a multiple of the key length apart. The index of coincidence: The chance that two letters drawn from a text are equal: the sum of f(f - 1) over the letter counts, divided by n(n - 1). About 0.066 to 0.069 for English and 1/26 = 0.0385 for random letters. More in “Monoalphabetic ciphers and frequency analysis” of the previous lesson does it without repeats: take every t-th letter, and only for the right t is each column a single Caesar cipher with the index of English. Step through the method on the passage:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- One flat profile. The whole ciphertext has an index of coincidence of 0.0483, between English at about 0.069 and random letters at 0.0385. That is the sign of several alphabets used in turn.
- Kasiski's repeats. Repeated plaintext under the same part of the key gives repeated ciphertext. The 65 distances between repeated triples share the factor 7 48 times, more than any other factor.
- A key length to test. The distances suggest 7, and the index can test every length from 1 to 12 without any repeats.
- Columns of one shift. If the key has t letters, every t-th letter was shifted by the same key letter, so each of the t columns is a Caesar cipher.
- Measure each column. A column of one shift keeps the shape of English, so its index is about 0.07. For t = 7 the mean is 0.0700; every other length stays between 0.0458 and 0.0489.
- A wrong length. With a wrong t each column still mixes several shifts, its index stays flat, and the method goes back for the next length.
- Solve the columns. Each of the 7 columns is solved like the Caesar ciphers of the first lesson, by the shift whose letter counts are closest to English.
- The key. The seven shifts spell MONSOON, and decrypting with it gives the passage. No part of the method needed the key or any plaintext.
The program runs the same steps and prints every number the figure uses:
"""Finding a Vigenere key's length, then the key, on the lesson's passage encrypted under MONSOON."""
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
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."
)
def vigenere(text, key, sign=1):
p = [UPPER.index(ch) for ch in text.upper() if ch in UPPER]
k = [UPPER.index(ch) for ch in key]
return "".join(UPPER[(x + sign * k[i % len(k)]) % 26] for i, x in enumerate(p))
def index_of_coincidence(text):
counts = Counter(text)
n = len(text)
return sum(f * (f - 1) for f in counts.values()) / (n * (n - 1))
def best_shift(column):
"""The Caesar key of one column: the shift whose decryption is closest to English (chi-squared)."""
def score(s):
counts = Counter(UPPER[(UPPER.index(ch) - s) % 26] for ch in column)
n = len(column)
return sum((counts[ch] - n * f / 100) ** 2 / (n * f / 100) for ch, f in zip(UPPER, ENGLISH))
return min(range(26), key=lambda s: (score(s), s))
ciphertext = vigenere(PASSAGE, "MONSOON")
print("letters:", len(ciphertext), f" index of coincidence of the whole ciphertext: {index_of_coincidence(ciphertext):.4f}")
# Kasiski: the distances between repeated three-letter sequences, and the factors they share.
seen, distances = {}, []
for i in range(len(ciphertext) - 2):
triple = ciphertext[i:i + 3]
if triple in seen:
distances.append(i - seen[triple])
seen[triple] = i
factors = Counter(f for d in distances for f in range(2, 13) if d % f == 0)
print(f"{len(distances)} repeat distances; the factors 2 to 12 they share, most common first:")
print(" " + ", ".join(f"{f}: {n}" for f, n in factors.most_common(6)))
# The index of coincidence of every t-th letter, averaged over the t columns.
means = {}
for t in range(1, 13):
columns = [ciphertext[i::t] for i in range(t)]
means[t] = sum(index_of_coincidence(c) for c in columns) / t
print(f" key length {t:2}: mean column index {means[t]:.4f}{' <- English-like' if means[t] > 0.06 else ''}")
# The first English-like length is the key's length, and each of its columns is a Caesar cipher: solve it by chi-squared.
length = min((t for t in means if means[t] > 0.06), default=max(means, key=means.get))
key = "".join(UPPER[best_shift(ciphertext[i::length])] for i in range(length))
print("key found:", key)
print("plaintext:", vigenere(ciphertext, key, -1)[:40], "...") Output
letters: 605 index of coincidence of the whole ciphertext: 0.0483 65 repeat distances; the factors 2 to 12 they share, most common first: 7: 48, 2: 29, 3: 19, 4: 16, 5: 13, 8: 10 key length 1: mean column index 0.0483 key length 2: mean column index 0.0478 key length 3: mean column index 0.0477 key length 4: mean column index 0.0476 key length 5: mean column index 0.0483 key length 6: mean column index 0.0470 key length 7: mean column index 0.0700 <- English-like key length 8: mean column index 0.0467 key length 9: mean column index 0.0476 key length 10: mean column index 0.0478 key length 11: mean column index 0.0489 key length 12: mean column index 0.0458 key found: MONSOON plaintext: WHENTHEFIRSTHEAVYRAINREACHEDTHECOASTTHEO ...
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 kasiski_ic.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
48 of the 65 distances between repeated triples have the factor 7, more than any other factor. Press Edit, replace MONSOON with a key of your own of up to 12 letters and run it again: the factor and the peak move to its length. The column index stands out at the same length:
The mean column index of coincidence for each assumed key length
Text description of the diagram
A bar chart with one bar for each assumed key length from 1 to 12. Each bar is the mean index of coincidence of the columns made by taking every t-th letter of the 605-letter ciphertext of the passage under the key MONSOON.
The values are: 1, 0.0483; 2, 0.0478; 3, 0.0477; 4, 0.0476; 5, 0.0483; 6, 0.0470; 7, 0.0700; 8, 0.0467; 9, 0.0476; 10, 0.0478; 11, 0.0489; 12, 0.0458. Only length 7, the length of MONSOON, reaches the index of English text, because only there is every column a single Caesar cipher.
At 7 the mean is 0.0700; at every other length from 1 to 12 it stays between 0.0458 and 0.0489. Each of the 7 columns is then solved like the first lesson’s Caesar ciphers, by the shift whose letter counts are closest to English, and the shifts spell MONSOON. How much ciphertext does that need? This run tries 20 random keys each of 4, 6, 8 and 10 letters on the first 100 to 605 letters of the passage, recorded here because it takes a few seconds:
"""How much ciphertext a Vigenere key needs: 20 random keys of each length, on the first 100 to 605 letters of the
passage. For each key length t from 1 to 12 the solver finds every column's Caesar key by chi-squared, then keeps the t
whose whole decryption is closest to English; a key found as two copies of a shorter one is shortened."""
import random
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
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 = [UPPER.index(ch) for ch in PASSAGE.upper() if ch in UPPER]
def chi_squared(counts):
n = sum(counts)
return sum((counts[i] - n * ENGLISH[i] / 100) ** 2 / (n * ENGLISH[i] / 100) for i in range(26))
def solve(cipher, max_len=12):
best = None
for t in range(1, max_len + 1):
shifts, total = [], [0] * 26
for j in range(t):
counts = [0] * 26
for c in cipher[j::t]:
counts[c] += 1
# Decrypting with shift s turns ciphertext letter (p + s) into p.
s = min(range(26), key=lambda s: (chi_squared([counts[(p + s) % 26] for p in range(26)]), s))
shifts.append(s)
for p in range(26):
total[p] += counts[(p + s) % 26]
score = chi_squared(total)
if best is None or score < best[0] - 1e-9:
best = (score, shifts)
key = best[1]
for p in range(1, len(key) + 1): # shorten a key that is a shorter key repeated
if len(key) % p == 0 and key == key[:p] * (len(key) // p):
return key[:p]
rng = random.Random(2026)
print("letters | key length 4 | 6 | 8 | 10 (keys recovered exactly, of 20)")
for n in (100, 200, 400, 605):
row = []
for m in (4, 6, 8, 10):
found = 0
for _ in range(20):
key = [rng.randrange(26) for _ in range(m)]
cipher = [(p + key[i % m]) % 26 for i, p in enumerate(PLAIN[:n])]
found += solve(cipher) == key
row.append(found)
print(f"{n:7} | " + " | ".join(f"{x:2}" for x in row)) Output
letters | key length 4 | 6 | 8 | 10 (keys recovered exactly, of 20)
100 | 19 | 20 | 18 | 0
200 | 20 | 20 | 20 | 20
400 | 20 | 20 | 20 | 20
605 | 20 | 20 | 20 | 20
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 vigenere_success.py
From 200 letters on, all 80 keys came out exactly. At 100 letters a 4-letter key leaves 25 letters per column and 19 of 20 keys were found, while a 10-letter key leaves 10 letters per column and none were. That fits the first lesson’s measurement for single Caesar ciphers: 10 letters are too few to trust, and from about 20 the true shift almost always comes first.
The same column measurement works on bytes, which is where this cipher turns up in code you own: XOR with a key of a few bytes. This check splits an encrypt function’s output into columns for every period from 1 to 16, and fails when the columns of any period repeat bytes three times as often as random bytes do. It fails XOR with a fixed 8-byte key and passes AES-256-GCM:
// A review check for "encryption" by XOR with a short repeating key, the Vigenere cipher on bytes. For every period t
// from 1 to 16 it splits the output into t columns (every t-th byte) and measures how often two bytes of a column are
// equal, as a multiple of the rate for random bytes (1.0). A column XORed with one key byte keeps the shape of its text,
// so at the key's length the columns score as high as text does; random-looking output stays near 1.0 at every period.
// Failing the check proves a leak; passing it proves nothing about a cipher.
const 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.';
const encode = (text) => new TextEncoder().encode(text);
/** How often two bytes of the array are equal, as a multiple of the rate for random bytes. */
function coincidenceRatio(bytes) {
const counts = new Array(256).fill(0);
for (const b of bytes) counts[b] += 1;
let pairs = 0;
for (const c of counts) pairs += c * (c - 1);
return (pairs / (bytes.length * (bytes.length - 1))) * 256;
}
/** The mean ratio of the t columns, for every period t from 1 to 16. */
function columnProfile(bytes) {
const means = [];
for (let t = 1; t <= 16; t++) {
let sum = 0;
for (let j = 0; j < t; j++) sum += coincidenceRatio(bytes.filter((_, i) => i % t === j));
means.push(sum / t);
}
return means;
}
/** The check a test suite runs on an encrypt function: no period may show columns at 3 times the random rate. */
function checkOutput(name, bytes) {
const means = columnProfile(bytes);
const top = Math.max(...means);
if (top < 3) return console.log(`${name}: pass`); // random output varies a little, so only the verdict prints
const period = means.findIndex((m) => m >= 0.8 * top) + 1; // the shortest period that scores like the best one
console.log(`${name}: FAIL, period ${period}, columns at ${means[period - 1].toFixed(1)} times the random rate`);
}
// The weak sample: XOR with a fixed 8-byte key kept in the code, as such functions do. Never write a real key in code.
const DEMO_KEY = encode('demo-key');
const xorEncrypt = (data) => data.map((b, i) => b ^ DEMO_KEY[i % DEMO_KEY.length]);
const weak = xorEncrypt(encode(PASSAGE));
checkOutput('xorEncrypt, 8-byte key', weak);
console.log(' its mean column ratio at periods 1 to 16:', columnProfile(weak).map((m) => m.toFixed(1)).join(' '));
console.log(' the plaintext as a whole:', coincidenceRatio(encode(PASSAGE)).toFixed(1));
// The fix: AES-256-GCM through Web Crypto, with a fresh random key and a fresh random 96-bit nonce.
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, false, ['encrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encode(PASSAGE)));
checkOutput(`aesGcmEncrypt (${sealed.length} bytes)`, sealed); Output
xorEncrypt, 8-byte key: FAIL, period 8, columns at 20.6 times the random rate its mean column ratio at periods 1 to 16: 7.5 9.5 7.4 13.0 7.4 9.4 7.3 20.6 7.6 9.4 7.3 12.8 7.3 9.5 7.1 20.4 the plaintext as a whole: 20.4 aesGcmEncrypt (785 bytes): pass
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node xor_period_check.mjs
At period 8, the key’s length, the columns repeat bytes 20.6 times as often as random bytes, about as often as the plaintext does as a whole (20.4), and the peak comes back at 16. No period of the AES-GCM output comes near the threshold.
Repeating-key XOR is this cipher on bytes
An “encrypt” function that XORs data with a fixed key of a few bytes is a Vigenère cipher on bytes: its period is the key length, and each byte column falls to statistics on its own. Treat data protected that way as readable, and replace the function with an authenticated cipher such as AES-GCM, as OWASP’s Cryptographic Storage Cheat Sheet advises.
Checkpoint
Check: Finding the key length
Playfair substitutes pairs of letters
Frequency analysis counts single letters, so the Playfair cipher: A digram cipher that replaces each pair of letters using a 5 by 5 square built from a keyword, with rules for pairs in one row, in one column or at the corners of a rectangle. It hides single letters but not pairs., Wheatstone’s design and the Handbook’s Example 7.51, enciphers pairs. The key is a 5 × 5 square: a keyword without repeated letters, then the rest of the alphabet, with I and J in one cell. The square of the GTU practical’s keyword, MONARCHY, with its rows and columns numbered:
| Row | 1 | 2 | 3 | 4 | 5 |
|---|---|---|---|---|---|
| 1 | M | O | N | A | R |
| 2 | C | H | Y | B | D |
| 3 | E | F | G | I | K |
| 4 | L | P | Q | S | T |
| 5 | U | V | W | X | Z |
The plaintext is split into pairs, with an X between two equal letters and after a lone last letter. Then three rules cover every pair:
- Same row: each letter moves one place to the right, wrapping round: AR becomes RM.
- Same column: each letter moves one place down, wrapping round: MU becomes CM.
- Rectangle: the letters mark two corners, and each is replaced by the corner in its own row: HS becomes BP.
Decryption runs the rules backwards: left instead of right and up instead of down. For the rectangle, the Handbook’s Example 7.51 takes each letter’s corner in its own column instead, which turns HS into PB; the practical’s answer, BP, needs the row, and so does this lesson’s program, so state the convention in code and tests. The program reproduces the practical and then measures the passage:
"""The Playfair cipher on the GTU 3161606 practical, a doubled letter, and what it does to letter and pair counts."""
from collections import Counter
UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
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."
)
def square(keyword):
"""5 x 5 letters: the keyword without repeats, then the rest of the alphabet; I and J share a cell."""
cells = ""
for ch in keyword.upper().replace("J", "I") + UPPER.replace("J", ""):
if ch in UPPER and ch not in cells:
cells += ch
return [cells[r * 5:r * 5 + 5] for r in range(5)]
def pairs(text):
"""Letter pairs; an X goes between a doubled pair and after a lone last letter."""
s = [ch for ch in text.upper().replace("J", "I") if ch in UPPER]
out, i = [], 0
while i < len(s):
a = s[i]
b = s[i + 1] if i + 1 < len(s) else "X"
if a == b:
out.append(a + "X")
i += 1
else:
out.append(a + b)
i += 2
return out
def playfair(pair_list, keyword, step=1):
"""step=1 encrypts, step=-1 decrypts. Returns each pair's result and the rule that made it."""
sq = square(keyword)
where = {sq[r][c]: (r, c) for r in range(5) for c in range(5)}
out = []
for a, b in pair_list:
(r1, c1), (r2, c2) = where[a], where[b]
if r1 == r2:
out.append((sq[r1][(c1 + step) % 5] + sq[r2][(c2 + step) % 5], "same row: one to the right"))
elif c1 == c2:
out.append((sq[(r1 + step) % 5][c1] + sq[(r2 + step) % 5][c2], "same column: one down"))
else:
out.append((sq[r1][c2] + sq[r2][c1], "rectangle: swap the columns"))
return out
def coincidence(units):
counts = Counter(units)
n = len(units)
return sum(f * (f - 1) for f in counts.values()) / (n * (n - 1))
print("square for MONARCHY:", " ".join(square("MONARCHY")))
for (pair, (result, rule)) in zip(pairs("ar mu hs ea"), playfair(pairs("ar mu hs ea"), "MONARCHY")):
print(f" {pair} -> {result} ({rule})")
print("balloon is split as", " ".join(pairs("balloon")), "->", " ".join(r for r, _ in playfair(pairs("balloon"), "MONARCHY")))
back = playfair([r for r, _ in playfair(pairs("balloon"), "MONARCHY")], "MONARCHY", -1)
print("and decrypts to", " ".join(r for r, _ in back))
plain_pairs = pairs(PASSAGE)
cipher_pairs = [r for r, _ in playfair(plain_pairs, "MONARCHY")]
plain_letters, cipher_letters = list("".join(plain_pairs)), list("".join(cipher_pairs))
print(f"single letters, index of coincidence: {coincidence(plain_letters):.4f} -> {coincidence(cipher_letters):.4f}")
print(f"letter pairs, index of coincidence: {coincidence(plain_pairs):.5f} -> {coincidence(cipher_pairs):.5f}"
f" (uniform pairs: {1 / 625:.5f})") Output
square for MONARCHY: MONAR CHYBD EFGIK LPQST UVWXZ AR -> RM (same row: one to the right) MU -> CM (same column: one down) HS -> BP (rectangle: swap the columns) EA -> IM (rectangle: swap the columns) balloon is split as BA LX LO ON -> IB SU PM NA and decrypts to BA LX LO ON single letters, index of coincidence: 0.0677 -> 0.0504 letter pairs, index of coincidence: 0.01067 -> 0.01067 (uniform pairs: 0.00160)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 playfair.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
Single letters are well hidden: their index falls from 0.0677, the passage with its inserted Xs, to 0.0504. But Playfair always replaces the same pair with the same pair, so the counts of pairs are only renamed, and their index is 0.01067 before and after, against 0.0016 for random pairs. The Handbook notes the same: analysis by single letters fails, analysis by 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. succeeds.
The GTU practical
Practical 2 of Gujarat Technological University’s syllabus for Cryptography and Network Security (3161606) gives the key MONARCHY, the plaintext “ar mu hs ea” and the ciphertext RM CM BP IM, which the program reproduces. Its practical 3 sets the Hill cipher of the next section.
Checkpoint
Check: Playfair's digraph rules
The Hill cipher multiplies blocks by a matrix
The Hill cipher: A cipher that multiplies each block of n letters by an n by n key matrix modulo 26. The key's determinant must share no factor with 26, and because the cipher is linear, known plaintext reveals the key., the Handbook’s Example 7.52, takes a block of n letters as a vector of numbers and multiplies it by an n × n key matrix modulo 26. The GTU practical uses n = 3 and this key:
In words: each ciphertext letter is a weighted sum of all three plaintext letters of its block. For “pay”, the row vector (15, 0, 24) times the first column gives 15 × 17 + 0 × 21 + 24 × 2 = 303, and 303 mod 26 = 17, the letter R. Decryption multiplies by the inverse matrix, which exists only when the determinant shares no factor with 26:
"""The Hill cipher with the 3 x 3 key of the GTU 3161606 practical: both conventions, the inverse key and how many
2 x 2 keys can be inverted at all."""
from collections import Counter
from math import gcd
UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
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."
)
K = [[17, 17, 5],
[21, 18, 21],
[2, 2, 19]]
def numbers(text):
return [UPPER.index(ch) for ch in text.upper() if ch in UPPER]
def encrypt_rows(text, key):
"""C = P K mod 26, each block of n letters a row vector (the practical's convention)."""
p, n = numbers(text), len(key)
out = []
for i in range(0, len(p), n):
block = p[i:i + n]
out += [sum(block[r] * key[r][c] for r in range(n)) % 26 for c in range(n)]
return "".join(UPPER[x] for x in out)
def encrypt_columns(text, key):
"""C = K P mod 26, each block a column vector (the Handbook's Example 7.52)."""
p, n = numbers(text), len(key)
out = []
for i in range(0, len(p), n):
block = p[i:i + n]
out += [sum(key[r][c] * block[c] for c in range(n)) % 26 for r in range(n)]
return "".join(UPPER[x] for x in out)
def det3(m):
(a, b, c), (d, e, f), (g, h, i) = m
return a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
def inverse3(m):
"""The inverse modulo 26: the adjugate times the inverse of the determinant."""
d_inv = pow(det3(m) % 26, -1, 26) # ValueError if the determinant shares a factor with 26
def minor(r, c):
rows = [row[:c] + row[c + 1:] for k, row in enumerate(m) if k != r]
return rows[0][0] * rows[1][1] - rows[0][1] * rows[1][0]
return [[d_inv * (-1) ** (r + c) * minor(c, r) % 26 for c in range(3)] for r in range(3)]
print("pay, rows (C = P K):", encrypt_rows("pay", K), " columns (C = K P):", encrypt_columns("pay", K))
secret = encrypt_rows("paymoremoney", K)
print("paymoremoney ->", secret)
print("determinant:", det3(K), "=", det3(K) % 26, "mod 26, gcd with 26:", gcd(det3(K) % 26, 26))
K_inv = inverse3(K)
print("inverse key:", K_inv)
print("decrypted with it:", encrypt_rows(secret, K_inv))
letters = "".join(ch for ch in PASSAGE.upper() if ch in UPPER) + "X" # 606 letters: whole blocks of three
for name, text in [("the passage", letters), ("under K", encrypt_rows(letters, K))]:
counts = Counter(text)
n = len(text)
print(f"index of coincidence, {name}: {sum(f * (f - 1) for f in counts.values()) / (n * (n - 1)):.4f}")
count = sum(1 for a in range(26) for b in range(26) for c in range(26) for d in range(26)
if gcd((a * d - b * c) % 26, 26) == 1)
print(f"invertible 2 x 2 keys: {count:,} of {26 ** 4:,}") Output
pay, rows (C = P K): RRL columns (C = K P): LNS paymoremoney -> RRLMWBKASPDH determinant: -939 = 23 mod 26, gcd with 26: 1 inverse key: [[4, 9, 15], [15, 17, 6], [24, 0, 17]] decrypted with it: PAYMOREMONEY index of coincidence, the passage: 0.0689 index of coincidence, under K: 0.0391 invertible 2 x 2 keys: 157,248 of 456,976
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hill.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 practical’s answer, RRL, comes from writing each block as a row vector; the Handbook writes blocks as column vectors, C = K P, and the same matrix then gives LNS. Neither is wrong, but code and tests must say which they use. The determinant is 23 modulo 26, which is coprime with 26, so the inverse exists and turns RRLMWBKASPDH back into PAYMOREMONEY. Many matrices fail that test: of the 456,976 matrices of size 2 × 2, only 157,248 can be inverted. Every ciphertext letter depends on every letter of its block, which hides single letters well: on the passage their index falls to 0.0391.
Checkpoint
Check: The Hill matrix and its inverse
Known plaintext gives away a Hill key
That mixing is linear, and the Handbook states the consequence plainly: the Hill cipher falls to a known-plaintext attack: An attack in which the adversary has some plaintexts together with their ciphertexts, such as a fixed file header or a greeting that every message starts with. More in “The symmetric cipher model and the Caesar cipher”. Each known block and its ciphertext block give three linear equations in the key’s nine entries. Three known blocks give all nine, in matrix form C = P K, and if the 3 × 3 matrix P of the known blocks has an inverse modulo 26, the key is K = P⁻¹ C. Suppose an attacker records the ciphertext of a message and knows only that messages start with a fixed phrase:
"""Known plaintext against the Hill cipher: three known blocks give the whole 3 x 3 key, K = P^-1 C mod 26."""
from math import gcd
UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
SECRET_KEY = [[17, 17, 5], [21, 18, 21], [2, 2, 19]] # the key the attacker does not know
def numbers(text):
return [UPPER.index(ch) for ch in text.upper() if ch in UPPER]
def times(a, b):
return [[sum(a[r][k] * b[k][c] for k in range(len(b))) % 26 for c in range(len(b[0]))] for r in range(len(a))]
def det3(m):
(a, b, c), (d, e, f), (g, h, i) = m
return a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
def inverse3(m):
d_inv = pow(det3(m) % 26, -1, 26)
def minor(r, c):
rows = [row[:c] + row[c + 1:] for k, row in enumerate(m) if k != r]
return rows[0][0] * rows[1][1] - rows[0][1] * rows[1][0]
return [[d_inv * (-1) ** (r + c) * minor(c, r) % 26 for c in range(3)] for r in range(3)]
def blocks(text):
p = numbers(text)
return [p[i:i + 3] for i in range(0, len(p) - len(p) % 3, 3)]
message = "Transfer the money to the river office before noon" # the attacker sees only its ciphertext
ciphertext = times(blocks(message), SECRET_KEY) # what the attacker records
def invertible_triple(known):
"""The first three blocks whose 3 x 3 matrix has an inverse modulo 26, or None."""
for i in range(len(known)):
for j in range(i + 1, len(known)):
for k in range(j + 1, len(known)):
if gcd(det3([known[i], known[j], known[k]]) % 26, 26) == 1:
return i, j, k
return None
# Not every known start is enough: three of its blocks must make a matrix with an inverse modulo 26.
for start in ("Transfer the funds", "Transfer the money"):
known = blocks(start)
print(f"{start!r}: blocks {[''.join(UPPER[x] for x in b) for b in known]}, invertible triple: {invertible_triple(known)}")
# The attacker knows that the message starts "Transfer the money", and has its ciphertext.
known = blocks("Transfer the money")
i, j, k = invertible_triple(known)
P = [known[i], known[j], known[k]]
found = times(inverse3(P), [ciphertext[i], ciphertext[j], ciphertext[k]]) # K = P^-1 C
print(f"blocks {i}, {j} and {k}: determinant {det3(P) % 26} mod 26")
print("key found:", found, " equal to the secret key:", found == SECRET_KEY)
print("whole message:", "".join(UPPER[x] for row in times(ciphertext, inverse3(found)) for x in row)) Output
'Transfer the funds': blocks ['TRA', 'NSF', 'ERT', 'HEF', 'UND'], invertible triple: None 'Transfer the money': blocks ['TRA', 'NSF', 'ERT', 'HEM', 'ONE'], invertible triple: (0, 1, 3) blocks 0, 1 and 3: determinant 3 mod 26 key found: [[17, 17, 5], [21, 18, 21], [2, 2, 19]] equal to the secret key: True whole message: TRANSFERTHEMONEYTOTHERIVEROFFICEBEFORENOON
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hill_known.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
“Transfer the funds” would not have been enough: none of its five blocks form an invertible triple. “Transfer the money” has one, blocks 0, 1 and 3 with determinant 3, and the key comes out exactly, together with the rest of the message. Nine of the known letters gave all nine entries of the key, with no search at all.
This is why modern block ciphers mix linear steps, which spread every input letter across the block, with non-linear substitution steps that break the link between known plaintext and the key. The cipher-internals module shows both in AES.
Key idea
A linear cipher turns known plaintext into linear equations, so a few known blocks solve its key outright; modern ciphers combine linear mixing with non-linear substitution for that reason.
Checkpoint
Check: Known plaintext against Hill
Each cipher hides one pattern and keeps another
The lesson’s programs measured the same 605-letter passage under every cipher of this module. A single number, the index of coincidence of single letters, sorts them; what remains visible decides how each is broken:
| Cipher, on the passage | Index of single letters | What still shows |
|---|---|---|
| None, the plaintext | 0.0692 | everything |
| Caesar or keyword substitution | 0.0692 | every letter count, under new names |
| Vigenère, key MONSOON | 0.0483 | a period: each of 7 columns at 0.0700 |
| Autokey, primer THUNDER | 0.0394 | a key that is English text |
| Playfair, square MONARCHY | 0.0504 | pair counts: 0.01067 before and after |
| Hill, the practical’s key | 0.0391 | linear: three known blocks give the key |
Each classical step hides one statistic and keeps another. Substitution hides letter names and keeps counts; the repeating key hides the profile and keeps a period; Playfair hides letters and keeps pairs; Hill mixes whole blocks and keeps linearity. The defence that works is the one the next lesson builds up to: many rounds of keyed, non-linear substitution and transposition on large blocks, so that no statistic of the plaintext survives in the ciphertext.
Checkpoint
Check: What each cipher leaves behind
Practise: a Vigenère analyser and a Hill cipher
Here the practice is a lab of two parts, both checked by sample tests in your browser. In part one you write the Vigenère cipher and this lesson’s key-length finder, tested on the passage, starting from code that lets spaces reset the key and picks a multiple of the true length. In part two you write the Hill cipher with its inverse key, on the practical’s matrix, starting from code that makes the convention slip and forgets the determinant:
Code lab · 2 steps
Analyse a Vigenère ciphertext, then build a Hill cipher
Write the Vigenère cipher and find a key length from the ciphertext alone, then write the Hill cipher with an inverse key modulo 26.
- A Vigenère cipher, and its key length from the ciphertext To do
- A Hill cipher with an inverse key modulo 26 To do
Exercise · Medium · Python, JavaScript
A Vigenère cipher, and its key length from the ciphertext
Write two functions in vigenere.py (JavaScript: vigenere.mjs, with the names in camelCase):
vigenere(text, key, decrypt=False)(JavaScript:vigenere(text, key, decrypt = false)) keeps only the letters oftext, in capitals, and moves letter number i (counting from 0) by the key letter at positioni % len(key), A being 0. Withdecrypttrue it moves them back.vigenere("MEETATTHEGATE", "STORM")is"EXSKMLMVVSSMS".key_length(ciphertext, max_len=12)(JavaScript:keyLength) returns the smallest t from 1 tomax_lenfor which the mean index of coincidence of the t columns (letters 0, t, 2t, …, then 1, t + 1, …) is at least 0.06, and 0 when no t reaches it.index_of_coincidence(JavaScript:indexOfCoincidence) is already written for you.
The sample tests use the lesson's 605-letter passage. The starter code reads the key from the start of every word instead of counting letters, and it picks the length with the largest index, which is often a multiple of the true one.
Python · Starter code · vigenere.py
UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def index_of_coincidence(letters):
"""The chance that two letters drawn from a string of capitals are equal (0 for fewer than two)."""
n = len(letters)
if n < 2:
return 0.0
return sum(letters.count(ch) * (letters.count(ch) - 1) for ch in UPPER) / (n * (n - 1))
def vigenere(text, key, decrypt=False):
"""The letters of text in capitals, letter i moved by key letter i % len(key) (back when decrypt is true)."""
sign = -1 if decrypt else 1
out = []
for word in text.upper().split():
# slip: the key starts again at every word, and punctuation is counted as a letter
for i, ch in enumerate(word):
if ch in UPPER:
out.append(UPPER[(UPPER.index(ch) + sign * UPPER.index(key[i % len(key)])) % 26])
return "".join(out)
def key_length(ciphertext, max_len=12):
"""The smallest t whose mean column index of coincidence is at least 0.06, or 0."""
means = [sum(index_of_coincidence(ciphertext[i::t]) for i in range(t)) / t for t in range(1, max_len + 1)]
return means.index(max(means)) + 1 # slip: the largest index is often a multiple of the true length The sample tests · test_vigenere.py
from vigenere import index_of_coincidence, key_length, vigenere
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."
)
LETTERS = "".join(ch for ch in PASSAGE.upper() if ch.isalpha())
def test_storm():
"""MEETATTHEGATE under STORM is EXSKMLMVVSSMS, and decrypts back"""
assert vigenere("MEETATTHEGATE", "STORM") == "EXSKMLMVVSSMS"
assert vigenere("EXSKMLMVVSSMS", "STORM", decrypt=True) == "MEETATTHEGATE"
def test_only_letters_take_key_letters():
"""spaces, digits and punctuation are dropped and take no key letter"""
assert vigenere("Meet at the gate!", "STORM") == "EXSKMLMVVSSMS"
assert vigenere("meet-at, 2 the gate", "storm".upper()) == "EXSKMLMVVSSMS"
def test_round_trip_on_the_passage():
"""the passage comes back from its ciphertext under MONSOON"""
assert vigenere(vigenere(PASSAGE, "MONSOON"), "MONSOON", decrypt=True) == LETTERS
def test_key_length_monsoon():
"""the passage under MONSOON has key length 7"""
assert key_length(vigenere(PASSAGE, "MONSOON")) == 7
def test_key_length_is_the_smallest():
"""a key of 5 gives 5, not its multiple 10; one of 3 gives 3, not 6, 9 or 12"""
assert key_length(vigenere(PASSAGE, "STORM")) == 5
assert key_length(vigenere(PASSAGE, "KEY")) == 3
def test_plain_text_and_no_period():
"""plain English has length 1, and a text that never reaches 0.06 gives 0"""
assert key_length(LETTERS) == 1
assert key_length("ABCDEFGHIJKLMNOPQRSTUVWXYZ" * 3, max_len=5) == 0
def test_the_given_index():
"""the index of coincidence given in the starter is exact on small strings"""
assert index_of_coincidence("AABB") == 4 / 12
assert index_of_coincidence("ABCD") == 0.0
assert index_of_coincidence("A") == 0.0
def test_the_longest_length():
"""a 12-letter key is found at the end of the search, and a longer one is out of range"""
assert key_length(vigenere(PASSAGE, "QWERTYUIOPAS")) == 12
assert key_length(vigenere(PASSAGE, "QWERTYUIOPAS"), max_len=11) == 0 JavaScript · Starter code · vigenere.mjs
const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
/** The chance that two letters drawn from a string of capitals are equal (0 for fewer than two). */
export function indexOfCoincidence(letters) {
const n = letters.length;
if (n < 2) return 0;
let pairs = 0;
for (const ch of UPPER) {
const f = letters.split(ch).length - 1;
pairs += f * (f - 1);
}
return pairs / (n * (n - 1));
}
/** The letters of text in capitals, letter i moved by key letter i % key.length (back when decrypt is true). */
export function vigenere(text, key, decrypt = false) {
const sign = decrypt ? -1 : 1;
let out = '';
for (const word of text.toUpperCase().split(/\s+/)) {
// slip: the key starts again at every word, and punctuation is counted as a letter
[...word].forEach((ch, i) => {
const p = UPPER.indexOf(ch);
if (p >= 0) out += UPPER[(((p + sign * UPPER.indexOf(key[i % key.length])) % 26) + 26) % 26];
});
}
return out;
}
/** The smallest t whose mean column index of coincidence is at least 0.06, or 0. */
export function keyLength(ciphertext, maxLen = 12) {
let best = 1;
let bestMean = -1;
for (let t = 1; t <= maxLen; t++) {
let sum = 0;
for (let i = 0; i < t; i++) {
let column = '';
for (let j = i; j < ciphertext.length; j += t) column += ciphertext[j];
sum += indexOfCoincidence(column);
}
// slip: the largest index is often a multiple of the true length
if (sum / t > bestMean) [best, bestMean] = [t, sum / t];
}
return best;
} The sample tests · vigenere.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { indexOfCoincidence, keyLength, vigenere } from './vigenere.mjs';
const 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.';
const LETTERS = PASSAGE.toUpperCase().replace(/[^A-Z]/g, '');
test('MEETATTHEGATE under STORM is EXSKMLMVVSSMS, and decrypts back', () => {
assert.equal(vigenere('MEETATTHEGATE', 'STORM'), 'EXSKMLMVVSSMS');
assert.equal(vigenere('EXSKMLMVVSSMS', 'STORM', true), 'MEETATTHEGATE');
});
test('spaces, digits and punctuation are dropped and take no key letter', () => {
assert.equal(vigenere('Meet at the gate!', 'STORM'), 'EXSKMLMVVSSMS');
assert.equal(vigenere('meet-at, 2 the gate', 'STORM'), 'EXSKMLMVVSSMS');
});
test('the passage comes back from its ciphertext under MONSOON', () => {
assert.equal(vigenere(vigenere(PASSAGE, 'MONSOON'), 'MONSOON', true), LETTERS);
});
test('the passage under MONSOON has key length 7', () => {
assert.equal(keyLength(vigenere(PASSAGE, 'MONSOON')), 7);
});
test('a key of 5 gives 5, not its multiple 10; one of 3 gives 3, not 6, 9 or 12', () => {
assert.equal(keyLength(vigenere(PASSAGE, 'STORM')), 5);
assert.equal(keyLength(vigenere(PASSAGE, 'KEY')), 3);
});
test('plain English has length 1, and a text that never reaches 0.06 gives 0', () => {
assert.equal(keyLength(LETTERS), 1);
assert.equal(keyLength('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.repeat(3), 5), 0);
});
test('the index of coincidence given in the starter is exact on small strings', () => {
assert.equal(indexOfCoincidence('AABB'), 4 / 12);
assert.equal(indexOfCoincidence('ABCD'), 0);
assert.equal(indexOfCoincidence('A'), 0);
});
test('a 12-letter key is found at the end of the search, and a longer one is out of range', () => {
assert.equal(keyLength(vigenere(PASSAGE, 'QWERTYUIOPAS')), 12);
assert.equal(keyLength(vigenere(PASSAGE, 'QWERTYUIOPAS'), 11), 0);
}); A hint
Number the letters of the text as they come, 0, 1, 2 …, and use only that number to choose the key letter: spaces and punctuation are dropped first, so they never take a key letter. For the key length, loop t from 1 upwards and return the first t whose columns text[i::t] (JavaScript: every t-th letter from i) have a mean index of at least 0.06: a multiple of the true length scores as high, so the first one that qualifies is the true length.
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
A Hill cipher with an inverse key modulo 26
Write three functions in hill.py (JavaScript: hill.mjs, with the names in camelCase) for keys of 2 x 2 or 3 x 3 whole numbers, given as lists of rows:
hill_encrypt(text, key)keeps the letters oftextin capitals, addsXuntil their count is a multiple of n, and turns each block of n letters, as a row vector p, into the row vectorp K mod 26, the convention of GTU 3161606, practical 3:hill_encrypt("pay", K)is"RRL"for its key K = [[17, 17, 5], [21, 18, 21], [2, 2, 19]].inverse_key(key)(JavaScript:inverseKey) returns the inverse of the key modulo 26, with every entry from 0 to 25, and raisesValueError(JavaScript: throws anError) when the determinant shares a factor with 26.hill_decrypt(text, key)encrypts with the inverse key, which undoes the encryption.
The starter code multiplies the key by a column vector, which gives "LNS" for "pay", and its inverse forgets to divide by the determinant.
Python · Starter code · hill.py
UPPER = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def _letters(text, n):
p = [UPPER.index(ch) for ch in text.upper() if ch in UPPER]
return p + [UPPER.index("X")] * (-len(p) % n)
def _det(m):
if len(m) == 2:
return m[0][0] * m[1][1] - m[0][1] * m[1][0]
(a, b, c), (d, e, f), (g, h, i) = m
return a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g)
def _adjugate(m):
if len(m) == 2:
return [[m[1][1], -m[0][1]], [-m[1][0], m[0][0]]]
def minor(r, c):
rows = [row[:c] + row[c + 1:] for k, row in enumerate(m) if k != r]
return rows[0][0] * rows[1][1] - rows[0][1] * rows[1][0]
return [[(-1) ** (r + c) * minor(c, r) for c in range(3)] for r in range(3)]
def hill_encrypt(text, key):
"""Each block of n letters, a row vector p, becomes p K mod 26; X pads the last block."""
n = len(key)
p = _letters(text, n)
out = []
for i in range(0, len(p), n):
block = p[i:i + n]
out += [sum(key[r][c] * block[c] for c in range(n)) % 26 for r in range(n)] # slip: K p, a column vector
return "".join(UPPER[x] for x in out)
def inverse_key(key):
"""The inverse of key modulo 26; ValueError when its determinant shares a factor with 26."""
if _det(key) % 2 == 0 or _det(key) % 13 == 0:
raise ValueError("the key has no inverse modulo 26")
return [[x % 26 for x in row] for row in _adjugate(key)] # slip: the determinant's inverse is missing
def hill_decrypt(text, key):
"""Encryption with the inverse key undoes the encryption."""
return hill_encrypt(text, inverse_key(key)) The sample tests · test_hill.py
from hill import hill_decrypt, hill_encrypt, inverse_key
K = [[17, 17, 5], [21, 18, 21], [2, 2, 19]] # GTU 3161606, practical 3
K2 = [[3, 3], [2, 5]]
def raises_value_error(call):
try:
call()
except ValueError:
return True
return False
def test_gtu_practical_3():
"""pay encrypts to RRL with the practical's key, and paymoremoney to RRLMWBKASPDH"""
assert hill_encrypt("pay", K) == "RRL"
assert hill_encrypt("pay more money", K) == "RRLMWBKASPDH"
def test_inverse_key():
"""the inverse of the practical's key modulo 26"""
assert inverse_key(K) == [[4, 9, 15], [15, 17, 6], [24, 0, 17]]
assert inverse_key(K2) == [[15, 17], [20, 9]]
def test_decrypt():
"""decryption undoes encryption, for 3 x 3 and 2 x 2 keys"""
assert hill_decrypt("RRLMWBKASPDH", K) == "PAYMOREMONEY"
assert hill_decrypt(hill_encrypt("Help is on the way", K2), K2) == "HELPISONTHEWAY"
def test_padding():
"""X fills the last block"""
assert hill_decrypt(hill_encrypt("pay more", K), K) == "PAYMOREXX"
assert hill_encrypt("HEL", K2) == hill_encrypt("HELX", K2)
def test_keys_without_an_inverse():
"""a determinant that shares a factor with 26 has no inverse"""
assert raises_value_error(lambda: inverse_key([[2, 4], [1, 3]]))
assert raises_value_error(lambda: inverse_key([[13, 0], [0, 1]]))
assert raises_value_error(lambda: hill_decrypt("ABCD", [[1, 2], [2, 4]])) JavaScript · Starter code · hill.mjs
const UPPER = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
const mod26 = (n) => ((n % 26) + 26) % 26;
function letters(text, n) {
const p = [...text.toUpperCase()].filter((ch) => UPPER.includes(ch)).map((ch) => UPPER.indexOf(ch));
while (p.length % n !== 0) p.push(UPPER.indexOf('X'));
return p;
}
function det(m) {
if (m.length === 2) return m[0][0] * m[1][1] - m[0][1] * m[1][0];
const [[a, b, c], [d, e, f], [g, h, i]] = m;
return a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
}
function adjugate(m) {
if (m.length === 2) return [[m[1][1], -m[0][1]], [-m[1][0], m[0][0]]];
const minor = (r, c) => {
const rows = m.filter((_, k) => k !== r).map((row) => row.filter((_, j) => j !== c));
return rows[0][0] * rows[1][1] - rows[0][1] * rows[1][0];
};
return [0, 1, 2].map((r) => [0, 1, 2].map((c) => ((r + c) % 2 === 0 ? 1 : -1) * minor(c, r)));
}
/** Each block of n letters, a row vector p, becomes p K mod 26; X pads the last block. */
export function hillEncrypt(text, key) {
const n = key.length;
const p = letters(text, n);
let out = '';
for (let i = 0; i < p.length; i += n) {
const block = p.slice(i, i + n);
for (let r = 0; r < n; r++) {
let sum = 0;
for (let c = 0; c < n; c++) sum += key[r][c] * block[c]; // slip: K p, a column vector
out += UPPER[mod26(sum)];
}
}
return out;
}
/** The inverse of key modulo 26; throws when its determinant shares a factor with 26. */
export function inverseKey(key) {
const d = mod26(det(key));
if (d % 2 === 0 || d % 13 === 0) throw new Error('the key has no inverse modulo 26');
return adjugate(key).map((row) => row.map(mod26)); // slip: the determinant's inverse is missing
}
/** Encryption with the inverse key undoes the encryption. */
export function hillDecrypt(text, key) {
return hillEncrypt(text, inverseKey(key));
} The sample tests · hill.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { hillDecrypt, hillEncrypt, inverseKey } from './hill.mjs';
const K = [[17, 17, 5], [21, 18, 21], [2, 2, 19]]; // GTU 3161606, practical 3
const K2 = [[3, 3], [2, 5]];
test('pay encrypts to RRL with the practical key, and paymoremoney to RRLMWBKASPDH', () => {
assert.equal(hillEncrypt('pay', K), 'RRL');
assert.equal(hillEncrypt('pay more money', K), 'RRLMWBKASPDH');
});
test('the inverse of the practical key modulo 26', () => {
assert.deepEqual(inverseKey(K), [[4, 9, 15], [15, 17, 6], [24, 0, 17]]);
assert.deepEqual(inverseKey(K2), [[15, 17], [20, 9]]);
});
test('decryption undoes encryption, for 3 x 3 and 2 x 2 keys', () => {
assert.equal(hillDecrypt('RRLMWBKASPDH', K), 'PAYMOREMONEY');
assert.equal(hillDecrypt(hillEncrypt('Help is on the way', K2), K2), 'HELPISONTHEWAY');
});
test('X fills the last block', () => {
assert.equal(hillDecrypt(hillEncrypt('pay more', K), K), 'PAYMOREXX');
assert.equal(hillEncrypt('HEL', K2), hillEncrypt('HELX', K2));
});
test('a determinant that shares a factor with 26 has no inverse', () => {
assert.throws(() => inverseKey([[2, 4], [1, 3]]));
assert.throws(() => inverseKey([[13, 0], [0, 1]]));
assert.throws(() => hillDecrypt('ABCD', [[1, 2], [2, 4]]));
}); A hint
For a row vector p, entry c of p K is the sum over r of p[r] * K[r][c]. The inverse is the adjugate (the transposed matrix of cofactors) times the inverse of the determinant modulo 26: for a 2 x 2 key [[a, b], [c, d]] that is d_inv * [[d, -b], [-c, a]] with d_inv = pow(a * d - b * c, -1, 26), which raises ValueError by itself when the determinant has no inverse.
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.
The self-check below covers all six ideas. It begins where you have answered least well, shows why each choice is right or wrong as soon as you pick it, and gives you another go at what 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): why does a Vigenère cipher defeat simple frequency analysis, and how is it still broken?
A Vigenère cipher shifts each letter by the key letter at its position, so one plaintext letter becomes several ciphertext letters and the letter counts of the whole text flatten: on this lesson’s passage the index of coincidence fell from 0.0692 to 0.0483 under a 7-letter key. But the key repeats. Kasiski’s method finds the period from the distances between repeated segments, and the index of coincidence finds it from the columns: taking every 7th letter gives 7 columns that are each a Caesar cipher, with the index of English, 0.0700. Each column is then solved by letter statistics, and in the lesson’s runs all 80 random keys of 4, 6, 8 and 10 letters came out from 200 letters of ciphertext on. The same reasoning applies to XOR with a short repeating key, which is why that is never encryption.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A desktop app protects its licence files by XOR with a fixed 8-byte key compiled into the binary, and copies of licence files circulate online. Assess the risk, and plan the fix, including what happens to licences already issued.
-
Mid to senior
Why does known plaintext break the Hill cipher completely, and what does that teach about the design of modern block ciphers? Give numbers for a 3 x 3 key.
Flashcards · 7 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
How do the Vigenère and autokey ciphers shift letters, and what do they do to the index of coincidence?
Vigenère shifts letter i by key letter i mod t, t Caesar ciphers interleaved; number only the letters, so spaces use no key letter. MEETATTHEGATE under STORM gives EXSKMLMVVSSMS. Under MONSOON the index fell from 0.0692 to 0.0483. The autokey cipher keys later letters with the plaintext itself, so the key never repeats (index 0.0394).
-
How do Kasiski's method and the index of coincidence find a Vigenère key length?
Kasiski: the same plaintext under the same part of the key gives the same ciphertext, so repeated segments sit a multiple of the key length apart (48 of 65 distances had the factor 7). Index of coincidence: take every t-th letter; only for the right t is each column English-like (0.0700 at 7, 0.0458 to 0.0489 elsewhere). Each column is then a Caesar cipher.
-
Why is an 'encrypt' function that XORs data with a short fixed key readable, and how can a check catch it?
It is a Vigenère cipher on bytes: its period is the key length, and each byte column falls to statistics on its own. Split the output into columns for periods 1 to 16 and fail when the columns of any period repeat bytes three times as often as random ones (20.6 times at period 8). AES-GCM passes. Replace the function with an authenticated cipher.
-
How does Playfair encipher a pair of letters, and what does it leave behind?
With a 5 × 5 key square (I and J share a cell): same row, each letter moves right; same column, each moves down; otherwise each takes the corner in its own row. Single letters are hidden (index 0.0504), but a pair always becomes the same pair, so digram counts are only renamed (index 0.01067 before and after).
-
How does the Hill cipher work, and why must code state its convention?
It multiplies a block of n letters by an n × n key matrix modulo 26, and decrypts with the inverse matrix, which exists only when the determinant shares no factor with 26. The practical writes blocks as rows (C = P K); the Handbook writes columns (C = K P). The same matrix then gives RRL or LNS, so say which you use.
-
Why do a few known blocks give away a Hill key?
The mixing is linear: each known block gives three linear equations in the key's nine entries. Three known blocks give all nine, in matrix form C = P K, and if the 3 × 3 matrix P of the known blocks has an inverse modulo 26, the key is K = P⁻¹ C, found by algebra and not by search. Modern ciphers pair linear mixing with non-linear substitution to break this link.
-
What does each classical cipher hide, and what does it keep?
Substitution hides letter names and keeps counts. A repeating key hides the profile and keeps a period. Playfair hides letters and keeps pair counts. Hill mixes whole blocks and keeps linearity. What works is many rounds of keyed, non-linear substitution and transposition on large blocks, so that no statistic of the plaintext survives.
Key takeaways
- A Vigenère cipher shifts letter i by key letter i mod t, which hides the letter profile; the autokey continues the key with the plaintext so that it never repeats.
- A repeating key leaves a period: Kasiski’s repeat distances and the column index of coincidence find it, and each column then falls like a Caesar cipher, from about 20 letters a column; on bytes, the same columns expose repeating-key XOR.
- Playfair substitutes pairs with a 5 × 5 square and three rules, which hides single letters but leaves pair counts exactly as they were.
- The Hill cipher multiplies blocks by an invertible matrix modulo 26; its convention matters, and its linearity lets three known blocks give the whole key.
- Every classical cipher hides one statistic and keeps another, which is why modern ciphers use many rounds of non-linear substitution and transposition on large blocks.
Terms in this lesson
- autokey cipher
- A cipher whose key is a short primer followed by the plaintext itself, so the key never repeats. It has no period to find, but its key has the statistics of the language.
- 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.
- Hill cipher
- A cipher that multiplies each block of n letters by an n by n key matrix modulo 26. The key's determinant must share no factor with 26, and because the cipher is linear, known plaintext reveals the key.
- Kasiski's method
- Finding the key length of a repeating-key cipher from the distances between repeated ciphertext segments, which tend to be multiples of the key length. Kasiski published it in 1863.
- Playfair cipher
- A digram cipher that replaces each pair of letters using a 5 by 5 square built from a keyword, with rules for pairs in one row, in one column or at the corners of a rectangle. It hides single letters but not pairs.
- polyalphabetic cipher
- A cipher that uses several substitution alphabets in turn, so that one plaintext letter can become different ciphertext letters. The Vigenère and autokey ciphers and the rotor machines are polyalphabetic.
- Vigenère cipher
- The repeating-key polyalphabetic cipher: letter i is shifted by key letter i mod t, so it is t Caesar ciphers interleaved. The name is a historical misattribution; Vigenère himself described an autokey.
References
- Handbook of Applied Cryptography, chapter 7 (section 7.3: Examples 7.51 and 7.52, Definition 7.53, Example 7.61, section 7.3.5; notes in section 7.8) (CRC Press (authors' copy, Menezes, van Oorschot and Vanstone))
- Handbook of Applied Cryptography, chapter 1 (section 1.5.2: polyalphabetic substitution, Example 1.31) (CRC Press (authors' copy, Menezes, van Oorschot and Vanstone))
- Cryptography and Network Security, subject 3161606 (practicals 2 and 3) (Gujarat Technological University)
- OWASP Cryptographic Storage Cheat Sheet (custom algorithms, cipher modes) (OWASP Foundation)
- Python 3.14 documentation: random, reproducibility and seeding (Python Software Foundation)
- Node.js 24 documentation: Web Crypto API (AES-GCM, generateKey, getRandomValues) (OpenJS Foundation (Node.js))
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress