Your country

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

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

Applied Cryptography for Developers  Module 2 – Classical ciphers and cryptanalysis

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.

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

What you will learn

  • Encrypt and decrypt with 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:

Interactive · line by line MEETATTHEGATE under the key STORM, letter by letter The flag under text is the letter the loop reads next. Watch k, the shift taken from the key letter at position i mod 5.

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

After line 8: text is 'MEETATTHEGATE'; key is 'STORM'; out is 'EXSKMLMVVSSMS'; i is 12; ch is 'E'; k is 14; p is 4.textM0E1E2T3A4T5T6H7E8G9A10T11E12key'STORM'out'EXSKMLMVVSSMS'i12ch'E'k14p4
  • just taken by the loop
  • changed in this step
  • passed by the loop
  • read next
  • where the loop stopped

Printed so far

EXSKMLMVVSSMS

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.

Line run 8
Next line end
Loop pass –
Every step as text
  1. Start. No line has run yet, so there are no variables. Python starts with line 1.
  2. Line 1. text = "MEETATTHEGATE" sets text to 'MEETATTHEGATE'.
  3. Line 2. key = "STORM" sets key to 'STORM'.
  4. Line 3. out = "" sets out to ''.
  5. Line 4. for i, ch in enumerate(text): takes the item at position 0 of text, numbered 0: i is 0 and ch is 'M'.
  6. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 18. Key point Position 0 takes the key letter S, so M moves 18 places and wraps round to E.
  7. Line 6. p = ord(ch) - 65 sets p to 12.
  8. Line 7. out += chr((p + k) % 26 + 65) changes out from '' to 'E'.
  9. Line 4. for i, ch in enumerate(text): takes the item at position 1 of text, numbered 1: i is 1 and ch is 'E'.
  10. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 19 (it was 18).
  11. Line 6. p = ord(ch) - 65 sets p to 4 (it was 12).
  12. Line 7. out += chr((p + k) % 26 + 65) changes out from '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.
  13. Line 4. for i, ch in enumerate(text): takes the item at position 2 of text, numbered 2: i is 2 and ch is 'E'.
  14. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 14 (it was 19).
  15. Line 6. p = ord(ch) - 65 p keeps its value, 4.
  16. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EX' to 'EXS'.
  17. Line 4. for i, ch in enumerate(text): takes the item at position 3 of text, numbered 3: i is 3 and ch is 'T'.
  18. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 17 (it was 14).
  19. Line 6. p = ord(ch) - 65 sets p to 19 (it was 4).
  20. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EXS' to 'EXSK'.
  21. Line 4. for i, ch in enumerate(text): takes the item at position 4 of text, numbered 4: i is 4 and ch is 'A'.
  22. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 12 (it was 17).
  23. Line 6. p = ord(ch) - 65 sets p to 0 (it was 19).
  24. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EXSK' to 'EXSKM'.
  25. Line 4. for i, ch in enumerate(text): takes the item at position 5 of text, numbered 5: i is 5 and ch is 'T'.
  26. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 18 (it was 12). 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.
  27. Line 6. p = ord(ch) - 65 sets p to 19 (it was 0).
  28. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EXSKM' to 'EXSKML'.
  29. Line 4. for i, ch in enumerate(text): takes the item at position 6 of text, numbered 6: i is 6 and ch is 'T'.
  30. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 19 (it was 18).
  31. Line 6. p = ord(ch) - 65 p keeps its value, 19.
  32. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EXSKML' to 'EXSKMLM'.
  33. Line 4. for i, ch in enumerate(text): takes the item at position 7 of text, numbered 7: i is 7 and ch is 'H'.
  34. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 14 (it was 19).
  35. Line 6. p = ord(ch) - 65 sets p to 7 (it was 19).
  36. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EXSKMLM' to 'EXSKMLMV'.
  37. Line 4. for i, ch in enumerate(text): takes the item at position 8 of text, numbered 8: i is 8 and ch is 'E'.
  38. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 17 (it was 14).
  39. Line 6. p = ord(ch) - 65 sets p to 4 (it was 7).
  40. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EXSKMLMV' to 'EXSKMLMVV'.
  41. Line 4. for i, ch in enumerate(text): takes the item at position 9 of text, numbered 9: i is 9 and ch is 'G'.
  42. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 12 (it was 17).
  43. Line 6. p = ord(ch) - 65 sets p to 6 (it was 4).
  44. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EXSKMLMVV' to 'EXSKMLMVVS'.
  45. Line 4. for i, ch in enumerate(text): takes the item at position 10 of text, numbered 10: i is 10 and ch is 'A'.
  46. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 18 (it was 12).
  47. Line 6. p = ord(ch) - 65 sets p to 0 (it was 6).
  48. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EXSKMLMVVS' to 'EXSKMLMVVSS'.
  49. Line 4. for i, ch in enumerate(text): takes the item at position 11 of text, numbered 11: i is 11 and ch is 'T'.
  50. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 19 (it was 18).
  51. Line 6. p = ord(ch) - 65 sets p to 19 (it was 0).
  52. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EXSKMLMVVSS' to 'EXSKMLMVVSSM'.
  53. Line 4. for i, ch in enumerate(text): takes the item at position 12 of text, numbered 12: i is 12 and ch is 'E'.
  54. Line 5. k = ord(key[i % len(key)]) - 65 sets k to 14 (it was 19).
  55. Line 6. p = ord(ch) - 65 sets p to 4 (it was 19).
  56. Line 7. out += chr((p + k) % 26 + 65) changes out from 'EXSKMLMVVSSM' to 'EXSKMLMVVSSMS'.
  57. Line 4. for i, ch in enumerate(text): finds no character at position 13 of text (it has 13 characters), so the loop ends.
  58. Line 8. print(out) prints EXSKMLMVVSSMS. 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:

Vigenère and autokey, and what they do to the index Python · vigenere/vigenere.py
"""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

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:

Timeline · 9 events in order Polyalphabetic ciphers and the methods that broke them Walk through the ciphers in order. Each new design answered a method of attack, and each was answered in turn.
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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.

  6. 1914

    Playfair solved attack

    Mauborgne is credited with the first known solution of the Playfair cipher, about sixty years after it was popularised.

  7. 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.

  8. 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.

  9. 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

  1. Question 1 of 2 Under the key STORM, which key letter shifts the 6th letter of the plaintext (position 5, counting from 0)?

    Choose one answer.

    A hint

    Position i uses key letter i mod 5.

    Show the answer: Question 1 of 2

    Answer: S

    Letter i is shifted by key letter i mod len(key). With a 5-letter key the positions 0, 5, 10 and so on all use S, which is why every 5th letter forms one Caesar cipher.

  2. Question 2 of 2 In the autokey cipher with the primer THUNDER, what shifts the 8th plaintext letter (position 7) of MEETATTHEGATE?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The 1st plaintext letter, M

    A primer of 7 letters keys positions 0 to 6; after that, letter i is shifted by plaintext letter i − 7. There is no period for Kasiski or the index to find, though the key still has the statistics of English.

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:

Interactive · stage by stage From a Vigenère ciphertext to its key Step through the method on the lesson's 605-letter ciphertext. Each step uses a number the program below prints.
From a Vigenère ciphertext to its key, step 8 of 8. Showing 605-letter ciphertext, Repeated triples, Guess a key length t, Split into t columns, Index of each column (mean 0.0700 at t = 7), Solve every column, The key.flat, try the next t605-letter ciphertextindex 0.0483Repeated triples48 of 65 distances by 7Guess a key length ttry t from 1 to 12Split into t columnsevery t-th letterIndex of each columnmean 0.0700 at t = 7Solve every columna Caesar shift eachThe keyMONSOON

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

Every step as text
  1. 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.
  2. 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.
  3. A key length to test. The distances suggest 7, and the index can test every length from 1 to 12 without any repeats.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. 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:

Kasiski's repeats, column indexes and the key, on the passage Python · period/kasiski_ic.py
"""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

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:

Bar chart of the mean column index for key lengths 1 to 12: all near 0.047 except one peak of 0.0700 at length 7.Mean column index, by assumed key length1234560.0700789101112

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:

Keys recovered, by length of text and of key Python · success/vigenere_success.py
"""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 period check that fails repeating-key XOR and passes AES-GCM JavaScript · period-xor/xor_period_check.mjs
// 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

  1. Question 1 of 2 The mean column index of coincidence of a Vigenère ciphertext is about 0.047 for every assumed key length from 1 to 12, except 0.0700 at one length. In the lesson's run that length is the key's length. What is it?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 7 letters

    Only the right length makes every column a single Caesar cipher, so only there do the columns keep the index of English. In the lesson's run that was 7, MONSOON, and Kasiski's repeats agreed: 48 of 65 distances had the factor 7.

  2. Question 2 of 2 A legacy tool protects configuration files by XOR with a fixed 8-byte key. What does this lesson tell you about it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It is a Vigenère cipher on bytes; its period is 8, and each of the 8 byte columns falls to statistics like a Caesar column

    Repeating-key XOR is the Vigenère cipher of bytes, and code review should treat it as plain text. The fix is an authenticated cipher such as AES-GCM with a key from a key store.

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:

Playfair on the practical's pairs, a doubled letter and the passage Python · playfair/playfair.py
"""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

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

  1. One question In the MONARCHY square (rows MONAR, CHYBD, EFGIK, LPQST, UVWXZ), with the rules of the GTU practical, what does the pair HS become?

    Choose one answer.

    Show the answer: One question

    Answer: BP

    A pair in different rows and columns marks the corners of a rectangle, and each letter is replaced by the corner in its own row. That is how the practical's HS becomes BP.

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:

K=(171752118212219)C=PKmod26K = \begin{pmatrix} 17 & 17 & 5 \\ 21 & 18 & 21 \\ 2 & 2 & 19 \end{pmatrix} \qquad C = P\,K \bmod 26

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 practical's Hill key: both conventions, the inverse and the key count Python · hill/hill.py
"""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

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

  1. One question The same Hill key gives RRL for "pay" in the GTU practical and LNS in the Handbook's convention. What explains it?

    Choose one answer.

    Show the answer: One question

    Answer: One writes each block as a row vector (C = P K), the other as a column vector (C = K P)

    Matrix multiplication is not commutative, so the convention is part of the cipher. State it in code and in tests: the practical's RRL needs C = P K.

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:

Solving the Hill key from a known start Python · hill-known/hill_known.py
"""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

“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

  1. One question To recover a 3 x 3 Hill key from known plaintext, the attacker needs three known blocks whose matrix has an inverse modulo 26. Why three, and why the inverse?

    Choose one answer.

    Show the answer: One question

    Answer: Three blocks give the 9 equations for the 9 key entries, and the inverse solves them as K = P^-1 C

    With C = P K and P invertible modulo 26, K = P^-1 C. The lesson's known prefix "Transfer the money" had blocks 0, 1 and 3 with determinant 3, so the key came out exactly.

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

  1. One question On the lesson's passage, Playfair lowered the index of coincidence of single letters from 0.0677 to 0.0504. What did it leave exactly as it was?

    Choose one answer.

    Show the answer: One question

    Answer: The index of coincidence of letter pairs, 0.01067 before and after

    Each classical step hides one statistic and keeps another. Playfair hides single letters but keeps pair counts, so the analyst moves from 26 letters to 625 pairs, more work but the same idea.

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.

  1. A Vigenère cipher, and its key length from the ciphertext To do
  2. 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 of text, in capitals, and moves letter number i (counting from 0) by the key letter at position i % len(key), A being 0. With decrypt true it moves them back. vigenere("MEETATTHEGATE", "STORM") is "EXSKMLMVVSSMS".
  • key_length(ciphertext, max_len=12) (JavaScript: keyLength) returns the smallest t from 1 to max_len for 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.

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 of text in capitals, adds X until their count is a multiple of n, and turns each block of n letters, as a row vector p, into the row vector p 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 raises ValueError (JavaScript: throws an Error) 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.

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.

Vigenère Cipher Encoder & Decoder Encrypt your own text with a key of your choice, then compare the key length your function finds.

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.

  1. Question 1 of 12 What does "Meet at the gate" become under the Vigenère key STORM, with only its letters numbered?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: EXSKMLMVVSSMS

    Each letter moves by the key letter at its position modulo 5: S, T, O, R, M, S, T, O, R, M, S, T, O. The lesson's program prints EXSKMLMVVSSMS and decrypts it back.

  2. Question 2 of 12 What does the Handbook of Applied Cryptography say about the cipher that carries Vigenère's name?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: The simple repeating-key cipher is wrongly attributed to Vigenère; his 1586 book described an autokey cipher

    History matters here because the stronger design, the autokey, was the one Vigenère actually proposed; the weak repeating key, the one with a period, carries his name.

  3. Question 3 of 12 What does Kasiski's method look for in a Vigenère ciphertext?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Repeated segments, whose distances tend to be multiples of the key length

    Kasiski factors the distances between repeats; the factor that occurs most is the likely key length. In the lesson's run 48 of the 65 distances shared the factor 7.

  4. Question 4 of 12 In the lesson's run, 100 letters were enough to recover 19 of 20 four-letter keys but none of 20 ten-letter keys. Why the difference?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: Each key letter is solved from its own column, and 100 letters give 25 letters per column for a 4-letter key but only 10 for a 10-letter key

    A key of t letters splits the ciphertext into t columns of n/t letters each, and every column must be solved. From 200 letters on, the solver found all 80 keys of 4, 6, 8 and 10 letters.

  5. Question 5 of 12 How does Playfair split the word "balloon" into pairs?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: BA LX LO ON

    The Handbook's rule: if the two letters of a pair are equal, an uncommon letter such as X goes between them and the text is regrouped. The lesson's program also adds an X after a lone last letter.

  6. Question 6 of 12 In the MONARCHY square (rows MONAR, CHYBD, EFGIK, LPQST, UVWXZ), what does the pair AR become?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: RM

    Same row: one to the right, wrapping at the end of the row. This is the practical's first pair, ar to RM.

  7. Question 7 of 12 The lesson's program counts the 2 x 2 matrices modulo 26 whose determinant has an inverse, out of 456,976. How many are there?

    Type a number.

    Show the answer: Question 7 of 12

    Answer: 157248 keys

    A key works only when its determinant shares no factor with 26. The program finds 157,248 such matrices, about 34 % of all 456,976.

  8. Question 8 of 12 A teammate picks the Hill key [[2, 4], [1, 3]]. What happens, and how should the code react?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Its determinant is 2, which shares the factor 2 with 26, so no inverse exists; the code must refuse the key before using it

    Check gcd(det K, 26) = 1 when the key is chosen, and fail loudly otherwise; Python's pow(det, -1, 26) raises ValueError, which is the check the lesson's code relies on.

  9. Question 9 of 12 Why does known plaintext break the Hill cipher so completely?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Encryption is linear, so known plaintext and ciphertext give linear equations for the key

    The Handbook calls the Hill cipher a linear transformation that falls under known-plaintext attack. Modern block ciphers are built with non-linear steps for exactly this reason.

  10. Question 10 of 12 What design lesson do modern block ciphers such as AES take from the Hill cipher?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Linear mixing alone is solvable from known plaintext, so ciphers combine it with non-linear substitution steps

    Linearity spreads every plaintext letter across the block, which is good diffusion, but it is also what makes the key solvable. Real ciphers alternate linear mixing with non-linear substitution, in many rounds.

  11. Question 11 of 12 The passage has an index of coincidence of 0.0692. What is it under the Vigenère key MONSOON, to four places?

    Type a number.

    Show the answer: Question 11 of 12

    Answer: 0.0483 (anything from 0.0482 to 0.0484 counts)

    Seven alphabets in turn spread each plaintext letter over several ciphertext letters, which pulls the index towards the random 0.0385. It does not reach it: each column still has the index of English.

  12. Question 12 of 12 An unknown classical ciphertext has an index of coincidence of 0.069, but its letter counts do not match English letter for letter. Which kind of cipher is most likely?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: A monoalphabetic substitution, which keeps the index but renames the letters

    The index sorts ciphers before any key is tried: about 0.069 means one alphabet; between that and 0.0385 means several, or pairs; 0.0385 means random-looking output.

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.

  1. 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.

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

  2. 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.

    Model answer, Pro. First step and follow-ups

Flashcards · 7 cards

Recall it, then check

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

  1. 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).

  2. 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.

  3. 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.

  4. 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).

  5. 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.

  6. 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.

  7. 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

Related tools

Report a problem with this lesson

Quick answers and tool search

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