Vigenère cipher reference

Vigenère Cryptanalysis in Python: From Key-Length Detection to Key Recovery

A useful Vigenère cracker is not one formula.

It is a pipeline.

The ciphertext first has to be normalized. Plausible key periods have to be generated. Each candidate period creates a different set of Caesar-like columns. Those columns have to produce a key. Harmonic periods have to be recognized. Finally, the resulting plaintext candidates need to be ranked and verified.

This article turns those stages into one standard-library Python implementation.

The program deliberately combines techniques that have already been tested separately:

average-column Index of Coincidence
→ candidate periods

chi-squared column scoring
→ candidate key for each period

tetragram language scoring
→ rank complete plaintexts

fundamental-period reduction
→ collapse repeated harmonic keys

The controlled fixture is designed to expose an important real failure mode.

The actual key is:

COMPUTER

with fundamental period:

8

Yet average-column IC ranks:

16

above:

8

The period-16 recovery is not nonsense. It returns:

COMPUTERCOMPUTER

which generates exactly the same keystream.

The final program therefore cannot simply take the highest IC peak and stop. It has to recognize that the 16-character result reduces to the eight-character fundamental key.

If you only need to encode or decode text with a known key, use the Vigenère Cipher Decoder and Encoder. This article is about recovering an unknown repeating key from ciphertext.

What this Python cracker is trying to solve

The input is assumed to be standard repeating-key Vigenère over A–Z.

The program does not know:

the key
the key length
the plaintext

It does know or assume:

the plaintext language is English
the ciphertext is long enough for statistical analysis
the candidate period lies within a chosen search range

For this worked run:

candidate periods: 2 through 20
ciphertext length: 600 letters
training text: 4,000 letters

The program searches from the ciphertext toward the key.

This article starts from ciphertext rather than teaching encryption. The ciphertext is treated as the starting evidence, and every later step is aimed at recovering and testing an unknown key.

The exact training and test data

The source material is the same verified normalized corpus used in the earlier benchmark articles, derived from Project Gutenberg eBook #11, Alice’s Adventures in Wonderland.

The complete normalized source corpus contains:

6,498 letters

SHA-256:

3070aad2cd74ac31a8a6a39c2ecc780392bbe9291904b599d41a352585b0957e

The first:

4,000 letters

form the training text.

Training SHA-256:

bf8113be5f5dccacf2009de0fa5341a421f99fa39b9a001231eb914f0f628f13

The cracking fixture uses the next:

600 letters

as held-out plaintext.

That plaintext was not included in the language-model training segment.

Plaintext SHA-256:

5d64491586e6bfd3ed9af58e98472e81476614b057c11f2c96ad6ae109a3f779

After encryption with the ground-truth key COMPUTER, the 600-letter ciphertext is:

GKMHHHXROCYTHMXFDSXDMMENCMITHMECKQQACDIKJSIXHWEEFKMHDNWKKBFXGXXFJSMGCMWRAOEXNMYIPSPPWHVEGFAWGRIRTGMCXPLZUYQGMASNNOFTCMWXGHFXHZWYGKMHWESJGPQWCGHZVKTTHLLVVIDCYWXYGQAGHXVSWHFWYKESDWFLULRFNCZVYKXFDSETYGWYGTAJHWLVTGQAZBRRNCZVFHAYCZXLBBGYYOEACMYGDMMGIPSWNOYEMAEEIWZVZKSDVVQGIHJKJSDTQXVVFCAGMTPCTCGCXMLVJOXAVNXKJSKLYKIRNZXDWDIUCBPLBXRRNWOTBTHSGSZPFEXYGKMNXHAEQBQHCWIRPRGENAIFVVQGNKCZPUQKYKCUQCDHBXARNYQSMTHCARALHMLVOWPSFXAFPRQGCGKYQKEWYPEJGJQGNHKVVCGIUZEZPGGSXXRCAGTTWTQVWDACUEMKVZQIBKIVNSSVYWXRDZQPFEQRFSAUMHPZFUXPMLXYGFQLULRFVVUCAHRZVSJRYIXRVWZNAHPUGBWTSTRUCZURYLJZTGFIBHYXJHIPMMLRVWFBCZLKDSXDHZXFQBQDZMLV

Ciphertext SHA-256:

8d319c22fcca6510fde1aa08bea801556f1731ed0f5f65d89d999ad6c1c49b29

The cracker receives the ciphertext and training text.

It does not receive the plaintext or key.

Stage 1: normalize consistently

The cryptanalytic code uses:

def normalize(text):
    return "".join(ch for ch in text.upper() if ch in ALPHABET)

That policy means:

uppercase A–Z only
spaces removed
punctuation removed
digits removed

The same normalization rule must be used for:

training data
ciphertext
scoring

A mismatch here can ruin period alignment before any statistical method gets a chance to work.

If the original cipher advanced its key over a different symbol policy, the preprocessing rule has to match that behavior.

Stage 2: rank candidate periods with average-column IC

For candidate period p, split the ciphertext into:

p

recurring columns.

Each column receives one fixed Caesar shift if p is compatible with the true repeating structure.

The program calculates ordinary IC for every column and averages the values:

def average_column_ic(ciphertext, period):
    columns = [ciphertext[offset::period] for offset in range(period)]
    return sum(index_of_coincidence(col) for col in columns) / period

The top ten scores for the 600-letter fixture are:

IC rank Candidate period Average column IC
1 16 0.07313399
2 8 0.06972973
3 12 0.05006803
4 4 0.05006711
5 20 0.04885057
6 18 0.04458185
7 14 0.04419839
8 6 0.04377104
9 10 0.04310734
10 2 0.04245262

The true fundamental period is not the highest score.

Period 16 wins.

That is not a bug.

A multiple of the true period can also split the ciphertext without mixing incompatible key shifts.

The broader behavior of IC, Kasiski, Friedman, and autocorrelation is tested in the key-length methods comparison. The Python program needs to behave correctly even when IC produces exactly this kind of harmonic ambiguity.

Do not throw away divisors of strong periods

Suppose a strong IC peak appears at:

16

Possible explanations include:

fundamental period 16
fundamental period 8
fundamental period 4
fundamental period 2

The program therefore does not evaluate only the top raw IC values.

It takes the strongest period seeds and adds their divisors:

def divisors(period):
    return {d for d in range(2, period) if period % d == 0}

For the fixture, the strongest six raw IC candidates are:

16, 8, 12, 4, 20, 18

Their divisors add candidates such as:

2
3
5
6
8
9
10

This makes the search more resistant to the common:

multiple outranks fundamental period

problem.

It is still a heuristic.

A difficult ciphertext may require additional evidence from Kasiski or autocorrelation rather than simply widening the IC list indefinitely.

Stage 3: recover one Caesar shift per column

Once a period is being tested, every recurring column is treated as a Caesar cipher.

The training segment supplies expected English monogram frequencies.

For candidate shift s:

χ²(s)
=
Σ (Observedᵢ - Expectedᵢ)²
---------------------------
          Expectedᵢ

The smallest chi-squared score wins that column.

The code is:

def recover_key_for_period(ciphertext, period, frequencies):
    key = []

    for offset in range(period):
        column = ciphertext[offset::period]

        best_shift = min(
            range(26),
            key=lambda shift:
                chi_square_for_shift(column, shift, frequencies),
        )

        key.append(chr(A + best_shift))

    return "".join(key)

For candidate period 8, the result is:

COMPUTER

For candidate period 16:

COMPUTERCOMPUTER

Both are statistically valid descriptions of the same repeating keystream.

For wrong periods, the recovered keys look very different.

Examples include:

period 4  → VSER
period 12 → USEEVBERVOXP
period 20 → VZERUOELTTEAVXQZVIER

The key-recovery step alone does not yet tell us which candidate produces the best complete plaintext.

Why this version keeps one shift per column

The controlled chi-squared-vs-n-gram benchmark showed that on short ciphertext, retaining several chi-squared shifts per column and reranking the combinations with n-grams can recover keys that independent best-shift selection misses.

This example is intentionally different.

The ciphertext contains:

600 letters

For the true period 8, each column contains:

75 letters

That is enough for the correct Caesar shift to win every period-8 column in this controlled fixture.

So this implementation uses one best shift per column to keep the end-to-end code readable.

For much shorter ciphertext, replace this step with the top-k candidate strategy from Chi-Squared vs N-Gram Scoring.

The architecture remains the same:

period candidates
→ key candidates
→ full-text language scoring

Only the width of the key search changes.

Stage 4: decrypt each candidate and score the full text

A candidate period plus recovered key produces a complete plaintext hypothesis.

Now sequence information becomes meaningful again.

The program uses a smoothed tetragram model trained on the same 4,000-letter segment.

For every reconstructed candidate plaintext it calculates an average natural-log score:

def tetragram_score(text, log_probabilities, floor):
    grams = len(text) - NGRAM_N + 1

    total = sum(
        log_probabilities.get(
            text[i:i + NGRAM_N],
            floor
        )
        for i in range(grams)
    )

    return total / grams

Less-negative scores are better.

The detailed modeling choices—overlapping n-grams, smoothing, log probabilities, and length normalization—are explained in N-Gram Scoring for Vigenère Cryptanalysis.

Here the scorer has a narrower job:

rank the full plaintext candidates produced by the period/key pipeline

The candidate table makes the correct result obvious

After period generation, key recovery, decryption, and tetragram scoring, the strongest results are:

Tested period Average IC Recovered key Reduced key Tetragram score
8 0.06972973 COMPUTER COMPUTER -10.76662068
16 0.07313399 COMPUTERCOMPUTER COMPUTER -10.76662068
4 0.05006711 VSER VSER -13.58364342
10 0.04310734 EEUKWOKLVT EEUKWOKLVT -13.58710164
20 0.04885057 VZERUOELTTEAVXQZVIER same -13.58758212
6 0.04377104 UELOCP UELOCP -13.60140994
12 0.05006803 USEEVBERVOXP same -13.62033333

Two candidates are tied far above the rest:

period 8
period 16

They are tied because they decrypt to the same plaintext.

That is exactly what should happen.

Stage 5: reduce repeated recovered keys to their fundamental period

The period-16 key is:

COMPUTERCOMPUTER

Its smallest exact repeating unit is:

COMPUTER

The program detects that with:

def fundamental_key(key):
    for period in range(1, len(key) + 1):
        if len(key) % period == 0:
            unit = key[:period]

            if unit * (len(key) // period) == key:
                return unit

    return key

So:

COMPUTERCOMPUTER
→ COMPUTER

This is not cosmetic cleanup.

Without period reduction, the cracker could report a mathematically redundant harmonic as though it were a distinct key.

The useful result is the smallest repeating key that generates the recovered keystream.

The exact program

The implementation below is the same code used to produce the published output.

It uses only Python’s standard library.

from pathlib import Path
from collections import Counter
import math
import string

ALPHABET = string.ascii_uppercase
A = ord("A")
NGRAM_N = 4
SMOOTHING = 0.01


def normalize(text):
    return "".join(ch for ch in text.upper() if ch in ALPHABET)


def index_of_coincidence(text):
    n = len(text)
    if n < 2:
        return 0.0
    counts = Counter(text)
    return sum(v * (v - 1) for v in counts.values()) / (n * (n - 1))


def average_column_ic(ciphertext, period):
    columns = [ciphertext[offset::period] for offset in range(period)]
    return sum(index_of_coincidence(col) for col in columns) / period


def build_monogram_model(training_text):
    counts = Counter(training_text)
    n = len(training_text)
    frequencies = {ch: counts[ch] / n for ch in ALPHABET}
    if any(p == 0 for p in frequencies.values()):
        raise ValueError("Training text must contain every A-Z letter for this chi-squared model.")
    return frequencies


def chi_square_for_shift(column, shift, frequencies):
    decoded = [(ord(ch) - A - shift) % 26 for ch in column]
    counts = Counter(decoded)
    n = len(column)
    score = 0.0
    for i, ch in enumerate(ALPHABET):
        expected = frequencies[ch] * n
        observed = counts.get(i, 0)
        score += (observed - expected) ** 2 / expected
    return score


def recover_key_for_period(ciphertext, period, frequencies):
    key = []
    for offset in range(period):
        column = ciphertext[offset::period]
        best_shift = min(
            range(26),
            key=lambda shift: chi_square_for_shift(column, shift, frequencies),
        )
        key.append(chr(A + best_shift))
    return "".join(key)


def decrypt(ciphertext, key):
    return "".join(
        chr((ord(ch) - A - (ord(key[i % len(key)]) - A)) % 26 + A)
        for i, ch in enumerate(ciphertext)
    )


def build_tetragram_model(training_text):
    counts = Counter(
        training_text[i:i + NGRAM_N]
        for i in range(len(training_text) - NGRAM_N + 1)
    )
    total = sum(counts.values())
    vocabulary = 26 ** NGRAM_N
    denominator = total + SMOOTHING * vocabulary

    log_probabilities = {
        gram: math.log((count + SMOOTHING) / denominator)
        for gram, count in counts.items()
    }
    floor = math.log(SMOOTHING / denominator)
    return log_probabilities, floor


def tetragram_score(text, log_probabilities, floor):
    grams = len(text) - NGRAM_N + 1
    if grams <= 0:
        raise ValueError("Candidate text is too short for tetragram scoring.")
    total = sum(
        log_probabilities.get(text[i:i + NGRAM_N], floor)
        for i in range(grams)
    )
    return total / grams


def fundamental_key(key):
    """Return the shortest exact repeating unit of a recovered key."""
    for period in range(1, len(key) + 1):
        if len(key) % period == 0:
            unit = key[:period]
            if unit * (len(key) // period) == key:
                return unit
    return key


def divisors(period):
    return {d for d in range(2, period) if period % d == 0}


def crack_vigenere(ciphertext, training_text, min_period=2, max_period=20, seed_count=6):
    ciphertext = normalize(ciphertext)
    training_text = normalize(training_text)

    frequencies = build_monogram_model(training_text)
    log_probs, floor = build_tetragram_model(training_text)

    ic_scores = {
        period: average_column_ic(ciphertext, period)
        for period in range(min_period, max_period + 1)
    }

    ic_order = sorted(ic_scores, key=lambda p: (-ic_scores[p], p))
    seeds = ic_order[:seed_count]

    # High IC values often occur at multiples of the true period.
    # Include divisors of the strongest periods before key recovery.
    candidate_periods = set(seeds)
    for period in seeds:
        candidate_periods.update(
            d for d in divisors(period)
            if min_period <= d <= max_period
        )

    candidates = []
    for period in sorted(candidate_periods):
        recovered_key = recover_key_for_period(ciphertext, period, frequencies)
        plaintext = decrypt(ciphertext, recovered_key)
        reduced_key = fundamental_key(recovered_key)
        language_score = tetragram_score(plaintext, log_probs, floor)
        candidates.append({
            "period": period,
            "average_ic": ic_scores[period],
            "recovered_key": recovered_key,
            "reduced_key": reduced_key,
            "language_score": language_score,
            "plaintext": plaintext,
        })

    # Higher language score is better. If two candidates decrypt identically
    # (for example period 8 and period 16 with a repeated key), prefer the
    # shorter reduced key.
    candidates.sort(
        key=lambda row: (-row["language_score"], len(row["reduced_key"]), row["period"])
    )

    return {
        "ic_order": [(p, ic_scores[p]) for p in ic_order],
        "seeds": seeds,
        "candidates": candidates,
        "best": candidates[0],
    }


if __name__ == "__main__":
    here = Path(__file__).resolve().parent
    training = (here / "vigenere-python-training-corpus.txt").read_text(encoding="ascii")
    ciphertext = (here / "vigenere-python-example-ciphertext.txt").read_text(encoding="ascii")

    result = crack_vigenere(ciphertext, training)

    print("Top average-column IC periods:")
    for period, score in result["ic_order"][:10]:
        print(f"{period:>2}  {score:.8f}")

    print()
    print("Recovered candidates:")
    for row in result["candidates"][:10]:
        print(
            f"period={row['period']:>2} "
            f"IC={row['average_ic']:.8f} "
            f"key={row['recovered_key']} "
            f"reduced={row['reduced_key']} "
            f"score={row['language_score']:.8f}"
        )

    print()
    print("Best reduced key:", result["best"]["reduced_key"])
    print("Best candidate period:", result["best"]["period"])
    print("Plaintext preview:", result["best"]["plaintext"][:120])

The only external data dependency in the reproducible run is the training text file.

For another English corpus, replace:

vigenere-python-training-corpus.txt

with your own normalized or normalizable training text.

The ciphertext can likewise be replaced with another A–Z Vigenère sample.

Exact program output

The verified fixture produces:

Top average-column IC periods:
16  0.07313399
 8  0.06972973
12  0.05006803
 4  0.05006711
20  0.04885057
18  0.04458185
14  0.04419839
 6  0.04377104
10  0.04310734
 2  0.04245262

Recovered candidates:
period= 8 IC=0.06972973 key=COMPUTER reduced=COMPUTER score=-10.76662068
period=16 IC=0.07313399 key=COMPUTERCOMPUTER reduced=COMPUTER score=-10.76662068
period= 4 IC=0.05006711 key=VSER reduced=VSER score=-13.58364342
period=10 IC=0.04310734 key=EEUKWOKLVT reduced=EEUKWOKLVT score=-13.58710164
period=20 IC=0.04885057 key=VZERUOELTTEAVXQZVIER reduced=VZERUOELTTEAVXQZVIER score=-13.58758212
period= 6 IC=0.04377104 key=UELOCP reduced=UELOCP score=-13.60140994
period=12 IC=0.05006803 key=USEEVBERVOXP reduced=USEEVBERVOXP score=-13.62033333
period= 5 IC=0.03994398 key=EELVS reduced=EELVS score=-13.62989298
period= 9 IC=0.03973373 key=SQRZHLVEZ reduced=SQRZHLVEZ score=-13.62989298
period= 3 IC=0.03996650 key=SEL reduced=SEL score=-13.63762350

Best reduced key: COMPUTER
Best candidate period: 8
Plaintext preview: EWASNOTAMOMENTTOBELOSTAWAYWENTALICELIKETHEWINDANDWASJUSTINTIMETOHEARITSAYASITTURNEDACORNEROHMYEARSANDWHISKERSHOWLATEITSG

The final result is:

Best reduced key: COMPUTER
Best candidate period: 8

The recovered plaintext begins:

EWASNOTAMOMENTTOBELOSTAWAYWENTALICELIKETHEWINDANDWASJUSTINTIMETOHEARITSAYASITTURNEDACORNEROHMYEARSANDWHISKERSHOWLATEITSGETTINGSHEWASCLOSEBEHINDITWHENSHETURNEDTH

and matches the complete 600-letter ground truth exactly.

Why the best candidate period is 8 even though period 16 had higher IC

There are two separate rankings in the pipeline.

The first is:

period plausibility

based on average-column IC.

The second is:

complete plaintext quality

after a key has actually been recovered.

Raw IC prefers:

16

But key recovery shows:

period 16 → COMPUTERCOMPUTER
period 8  → COMPUTER

Both generate the same plaintext.

Once the recovered period-16 key is reduced to its smallest repeating unit, the distinction disappears.

The program then prefers the shorter fundamental representation:

COMPUTER
period 8

This is stronger reasoning than:

choose the biggest IC number

because it checks whether the period hypothesis leads to an actual coherent key and plaintext.

Why wrong periods can still produce plausible-looking keys

At period 4 the program recovers:

VSER

That is not evidence that VSER is a meaningful word or that the method is hallucinating a key.

For every tested period, chi-squared is forced to choose the best Caesar shift available for each column.

Even a wrong period therefore produces some key.

The question is whether those shifts collectively reconstruct plausible language.

For VSER, the tetragram score is:

-13.58364342

versus:

-10.76662068

for the correct plaintext.

The full-text language model rejects the wrong period decisively in this controlled case.

That is why period detection and key recovery should not be evaluated in isolation.

The code is intentionally modular

Each function has one job:

normalize()
→ representation

index_of_coincidence()
average_column_ic()
→ period evidence

build_monogram_model()
chi_square_for_shift()
recover_key_for_period()
→ column/key recovery

decrypt()
→ reconstruct candidate plaintext

build_tetragram_model()
tetragram_score()
→ full-text ranking

fundamental_key()
→ remove harmonic redundancy

crack_vigenere()
→ orchestrate the pipeline

That separation makes testing easier.

If the output is wrong, you can ask:

Was normalization wrong?
Did period ranking fail?
Did one column recover the wrong shift?
Did the language scorer rank candidates badly?
Was a repeated key not reduced?

A single monolithic “crack” function makes those failures much harder to diagnose.

How to validate the implementation

Do not trust a cryptanalysis program merely because one example works.

At minimum, test the components separately.

Check IC against known periods

Encrypt known plaintext with controlled keys and verify that the correct period or a harmonic family receives elevated column IC.

The earlier key-length comparison provides thousands of controlled period-detection results for this purpose.

Check Caesar recovery independently

Give one column a known Caesar shift and confirm that the chi-squared routine returns it when enough text is available.

Check decryption against fixed vectors

Known-answer fixtures are better than round-trip tests alone because two complementary bugs can sometimes cancel each other.

The site’s verified Vigenère test vectors provide fixed plaintext/key/ciphertext triples for this purpose.

Check the language scorer separately

Score known English against deliberately corrupted alternatives.

The scorer should rank the intended language higher under its documented model.

Check harmonic reduction

Verify cases such as:

DOGDOG → DOG
COMPUTERCOMPUTER → COMPUTER
ABCABCABC → ABC

and make sure non-periodic keys remain unchanged.

What this cracker deliberately does not attempt

This script is a transparent baseline, not a universal Vigenère solver.

It assumes:

standard repeating-key Vigenère
A–Z alphabet
English-like plaintext
candidate periods within 2–20
enough ciphertext for one-best-shift column recovery

It does not automatically handle:

autokey Vigenère
running-key ciphers
keyed alphabets
custom alphabets
unknown languages
very short ciphertext robustly

Those cases change the model.

The point of the program is not to hide every cryptanalytic decision behind automation.

It is to make the decisions visible and testable.

How to strengthen it for harder ciphertext

The most useful upgrades follow directly from the weaknesses of the baseline.

For short samples:

retain several chi-squared shifts per column
use beam search or combination search
rerank full keys with n-grams

For uncertain periods:

combine IC with Kasiski
inspect autocorrelation peaks
evaluate divisors and multiples explicitly

For domain-specific plaintext:

train monogram and n-gram models on representative text

For longer candidate keys:

avoid exhaustive Cartesian products
use beam search or another guided search strategy

For non-English text:

replace the English language model

The architecture stays useful because each improvement changes one stage rather than requiring a completely different cracker.

Why this pipeline works on the fixture

The 600-letter ciphertext gives the true eight-position key about:

600 / 8 = 75

letters per column.

That is enough for stable monogram recovery in this particular sample.

The period structure is strong enough that both:

8
16

rise to the top of the IC ranking.

The recovered keys then expose the relationship directly:

8  → COMPUTER
16 → COMPUTERCOMPUTER

Finally, the reconstructed English plaintext scores much better than the candidates produced by incompatible periods.

The stages reinforce one another.

That is the main lesson of the implementation.

The practical Python workflow

A useful end-to-end Vigenère cracker can therefore be organized as:

ciphertext
↓
normalize
↓
rank candidate periods
↓
add divisors of strong harmonic periods
↓
recover Caesar shift for each key position
↓
assemble candidate keys
↓
decrypt complete candidate plaintexts
↓
rank them with a language model
↓
reduce repeated keys to their fundamental period
↓
verify the winning plaintext

For the controlled fixture, that pipeline starts with a 600-letter ciphertext whose highest raw IC peak is misleading and still ends at the correct fundamental key:

COMPUTER

That is what a practical cryptanalysis script should do.

It should not merely calculate statistics.

It should connect those statistics into a sequence of falsifiable decisions until a key and plaintext survive the entire pipeline.


Written by Marcus Hale

Editorially reviewed by VigenereDecoder.com editorial team

Written by Marcus Hale

Editorially reviewed by VigenereDecoder.com editorial team

Similar Posts