Vigenère Cipher — Key Reuse Breaks 'Unbreakable' Encryption
WEP's 5-minute crack via key reuse echoes Vigenère's fatal repetition.
20+ years shipping performance-critical code where algorithms decide the bill. Lessons pulled from things that broke in production.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- Vigenère encrypts with a repeating keyword, applying different Caesar shifts per letter — polyalphabetic substitution.
- Encryption: C_i = (P_i + K_{i mod len(K)}) mod 26. Decryption reverses the shift.
- Breaking it: Kasiski test finds keyword length from repeated ciphertext fragments (distances = multiples of key length).
- Index of Coincidence (IC) confirms length: English IC ≈ 0.065, random IC ≈ 0.038. Correct split yields English-level IC per stream.
- Performance insight: Full cryptanalysis runs in <100ms for 500-char ciphertext — Kasiski + IC sweep + chi-squared per stream.
- Production failure: never use Vigenère for real security — broken in 1863. Modern ciphers (AES-GCM, ChaCha20) fix the repeating key problem with CSPRNG keystreams.
- Biggest mistake: implementing Vigenère with key index incrementing on non-letters — a space consumes a key position, breaking alignment.
The Vigenère cipher is a polyalphabetic substitution cipher that encrypts text by shifting each letter by a key-dependent amount, using a repeating keyword to select from 26 Caesar shifts. It was invented in the 16th century and famously considered 'unbreakable' for 300 years because it flattens the single-letter frequency distributions that break simple substitution ciphers.
The key insight is that each plaintext letter maps to multiple ciphertext letters depending on its position in the message, which hides the natural letter frequencies of the underlying language. This made it the gold standard of manual encryption until the mid-19th century, when Friedrich Kasiski and Charles Babbage independently cracked it by exploiting the cipher's fatal flaw: key repetition.
The fundamental weakness is that when the key is reused across a long message or multiple messages, the cipher becomes a set of interleaved Caesar ciphers — one for each position in the key cycle. If you can determine the key length (say, 8 characters), you can split the ciphertext into 8 separate columns, each encrypted with a single shift.
At that point, each column is trivially breakable with frequency analysis. The Kasiski examination finds the key length by looking for repeated ciphertext trigrams (three-letter sequences) that likely correspond to common plaintext words like 'the' or 'and' encrypted with the same key fragment.
The Index of Coincidence (IC) provides statistical confirmation: a correctly aligned column will have an IC close to 0.067 (English), while random text hovers around 0.038.
In practice, Vigenère is never used for modern encryption — it's a teaching tool for demonstrating why key reuse destroys security. The same principle applies to any stream cipher or one-time pad variant: if you reuse the keystream, you leak information.
Real-world examples include the Venona project's decryption of Soviet one-time pad reuse, and WEP's RC4 key reuse that made 802.11b networks trivially crackable. The fix is either a non-repeating key (one-time pad with true randomness) or a modern cipher like AES-GCM that uses a unique nonce per message.
Understanding Vigenère's failure mode is essential for any engineer building cryptographic systems — it's the canonical example of why you must never, ever reuse a keystream.
Imagine Caesar cipher, but instead of shifting every letter by the same amount, you use a keyword to shift each letter differently. The word 'LEMON' means: shift the first letter by L (11), the second by E (4), the third by M (12), and so on — repeating the keyword across the message. This flattens the letter frequency peaks that make regular Caesar ciphers trivially breakable. For 300 years, nobody could crack it. Then Kasiski noticed that repeated ciphertext fragments reveal the key length, and the whole house of cards collapses.
For three centuries, the Vigenère cipher was known as 'le chiffre indéchiffrable' — the indecipherable cipher. From the 1580s until the 1860s, it resisted every published attack. Cryptanalysts who could break any monoalphabetic substitution cipher in an afternoon were completely stumped by Vigenère. The reason is simple but profound: frequency analysis — counting letter occurrences — depends on each letter always encrypting to the same ciphertext letter. Vigenère shatters that assumption by using multiple alphabets.
The story of how it fell is one of the great detective stories in cryptography. In 1854, Charles Babbage — yes, the man who designed the first mechanical computer — privately cracked the Vigenère cipher. He never published his method, possibly because the British government wanted the capability kept secret. Nine years later, Friedrich Kasiski, a retired Prussian infantry officer, independently published the same attack. The Kasiski examination — finding repeated ciphertext substrings to determine keyword length, then applying frequency analysis to each Caesar-shifted subgroup — is one of the most elegant examples of turning a cipher's own structure against itself.
I've taught this material in university cryptography courses and in corporate security training. The reason I keep coming back to Vigenère is that it perfectly illustrates the fundamental tension in cryptography: making a cipher complex enough to resist analysis, but not so complex that it becomes impractical. Every modern cipher — AES, ChaCha20, even TLS handshakes — is wrestling with the same trade-off. Vigenère just makes it visible.
Understanding why Vigenère's repeating key fails is exactly why one-time pads use a non-repeating key the same length as the message. Understanding Kasiski's attack teaches you to think like an attacker — looking for structural patterns the cipher designer didn't intend. And understanding the Index of Coincidence gives you a statistical tool that's still used in modern cryptanalysis, from detecting steganography to analysing encrypted network traffic.
By the end of this article, you'll be able to implement Vigenère from scratch, run the full cryptanalysis pipeline (Kasiski test → IC confirmation → per-stream frequency analysis → key recovery), and explain exactly why polyalphabetic substitution is stronger than monoalphabetic — but still fundamentally vulnerable to statistical attack.
Why the Vigenère Cipher Is Not Unbreakable
The Vigenère cipher is a polyalphabetic substitution cipher that encrypts text by shifting each letter by a key-dependent amount. Unlike a Caesar cipher (single shift), Vigenère uses a repeating keyword to select a different shift for each character — creating the illusion of randomness. The core mechanic: plaintext letter P at position i is encrypted as (P_i + K_{i mod m}) mod 26, where m is the key length.
In practice, the cipher's security collapses entirely when the same key is reused across multiple messages. If an attacker captures two ciphertexts encrypted with the same key, they can subtract them (mod 26) to cancel the key, leaving the difference of the plaintexts. With enough text, frequency analysis on the difference reveals both messages. The effective strength is not O(26^m) but O(m * 26) — the key length is the only real barrier, and it's trivially broken by Kasiski examination or index of coincidence.
Use Vigenère only as a teaching tool for polyalphabetic concepts or in toy systems where the key is single-use and as long as the message (a one-time pad). In any real system — network protocols, file encryption, password storage — Vigenère is catastrophically weak. Modern systems must use authenticated encryption (AES-GCM, ChaCha20-Poly1305) with unique nonces per message.
How Vigenère Actually Works — Step by Step
Vigenère is a polyalphabetic substitution cipher. Unlike Caesar, which uses one fixed shift for every letter, Vigenère uses a keyword where each letter defines a different shift value. The keyword repeats across the entire message.
The encryption formula is simple: for each plaintext letter at position i, compute ciphertext[i] = (plaintext[i] + key[i mod key_length]) mod 26. Decryption reverses: plaintext[i] = (ciphertext[i] - key[i mod key_length]) mod 26.
Let's work through ATTACKATDAWN with key LEMON, letter by letter:
Position 0: A(0) + L(11) = 11 mod 26 = L Position 1: T(19) + E(4) = 23 mod 26 = X Position 2: T(19) + M(12) = 31 mod 26 = F Position 3: A(0) + O(14) = 14 mod 26 = O Position 4: C(2) + N(13) = 15 mod 26 = P Position 5: K(10) + L(11) = 21 mod 26 = V ← key wraps back to L Position 6: A(0) + E(4) = 4 mod 26 = E Position 7: T(19) + M(12) = 31 mod 26 = F Position 8: D(3) + O(14) = 17 mod 26 = R Position 9: A(0) + N(13) = 13 mod 26 = N Position 10: W(22) + L(11) = 33 mod 26 = H Position 11: N(13) + E(4) = 17 mod 26 = R
Result: LXFOPVEFRNHR
Notice what happened at position 5: the keyword wrapped around. LEMON is 5 letters, so position 5 reuses L. This wrapping is the critical weakness. If the key were as long as the message and never repeated, the cipher would be unbreakable — that's the One-Time Pad. The repeating key creates periodic patterns that statistical analysis can detect.
The Tabula Recta (Vigenère table) is a 26×26 grid of alphabets, each row shifted by one position. You can use it as a lookup table: find the plaintext letter in the top row, find the key letter in the left column, and read the ciphertext letter at their intersection. It's a nice visual aid, but in practice, modular arithmetic is faster.
# io.thecodeforge.crypto.classical.VigenereCipher def vigenere_encrypt(text: str, key: str) -> str: """Encrypt plaintext using Vigenère cipher with the given key. Non-alphabetic characters pass through unchanged. The key is case-insensitive — all shifts use uppercase key values. """ key = key.upper() result = [] ki = 0 # key index — only increments for alphabetic characters for ch in text: if ch.isalpha(): base = ord('A') if ch.isupper() else ord('a') shift = ord(key[ki % len(key)]) - ord('A') result.append(chr((ord(ch) - base + shift) % 26 + base)) ki += 1 else: result.append(ch) # spaces, punctuation pass through return ''.join(result) def vigenere_decrypt(text: str, key: str) -> str: """Decrypt Vigenère ciphertext with the given key.""" key = key.upper() result = [] ki = 0 for ch in text: if ch.isalpha(): base = ord('A') if ch.isupper() else ord('a') shift = ord(key[ki % len(key)]) - ord('A') result.append(chr((ord(ch) - base - shift) % 26 + base)) ki += 1 else: result.append(ch) return ''.join(result) # --- Step-by-step trace to show exactly how encryption works --- def vigenere_trace(text: str, key: str): """Print a letter-by-letter trace of the Vigenère encryption.""" key = key.upper() ki = 0 print(f" {'Pos':>3} {'Plain':>5} {'Key':>3} {'Shift':>5} {'Cipher':>6}") print(f" {'---':>3} {'-----':>5} {'---':>3} {'-----':>5} {'------':>6}") for i, ch in enumerate(text): if ch.isalpha(): base = ord('A') if ch.isupper() else ord('a') p_val = ord(ch) - base k_letter = key[ki % len(key)] k_val = ord(k_letter) - ord('A') c_val = (p_val + k_val) % 26 c_letter = chr(c_val + base) print(f" {i:>3} {ch:>5} {k_letter:>3} {k_val:>5} {c_letter:>6}") ki += 1 else: print(f" {i:>3} {repr(ch):>5} {'—':>3} {'—':>5} {repr(ch):>6}") # --- Basic demo --- plaintext = 'ATTACKATDAWN' key = 'LEMON' ct = vigenere_encrypt(plaintext, key) print(f"Plaintext: {plaintext}") print(f"Key: {key}") print(f"Ciphertext: {ct}") print(f"Decrypted: {vigenere_decrypt(ct, key)}") print() print("Encryption trace:") vigenere_trace(plaintext, key) # --- Tabula Recta (first 10 rows/cols for reference) --- print("\nTabula Recta (partial):") print(f" {'':>3}", end='') for c in 'ABCDEFGHIJ': print(f" {c}", end='') print() for row_letter in 'ABCDEFGHIJ': print(f" {row_letter} ", end='') for col_letter in 'ABCDEFGHIJ': val = (ord(row_letter) - ord('A') + ord(col_letter) - ord('A')) % 26 print(f" {chr(val + ord('A'))}", end='') print()
ch.isalpha(): before incrementing ki. This is the single most common Vigenère implementation error. I've caught this bug in production code for a hobby encryption tool — the author couldn't figure out why messages with spaces decrypted incorrectly.Kasiski Examination — Finding the Key Length from Repeated Fragments
The Kasiski test is beautifully simple in concept. If the same plaintext fragment happens to align with the same position in the repeating key, it encrypts to the same ciphertext fragment. The distance between these repeated ciphertext fragments must be a multiple of the key length. Find enough repeated fragments, compute their distances, take the GCD — you have the key length.
Let me show you why this works with a concrete example. Suppose the key is CRYPTO (length 6) and the plaintext contains the word 'THE' at two positions that are 18 characters apart. Since 18 is a multiple of 6, both occurrences of 'THE' align with the same key segment 'CRY' — and both encrypt to the same ciphertext. If we find that 'THE' appears at distances 18, 24, and 30, the GCD of all three is 6.
In practice, you look for repeated substrings of length 3 or more. Shorter substrings produce too many false matches (common trigrams like 'THE' and 'AND' appear by chance). Longer substrings are rarer and give you fewer data points. Length 3-5 is the sweet spot.
One thing I learned running this against real ciphertexts: the raw GCD of all distances is often noisy. You'll get distances that aren't multiples of the key length due to coincidental plaintext repetitions. The robust approach is to collect all distances, factor each one, and find the most common factor — that's almost always the true key length. Alternatively, use the Index of Coincidence (next section) to confirm.
A production tip from a CTF team I mentored: if the Kasiski test gives you a key length of 12, try divisors too — the actual key might be length 4 or 6, and length 12 is just a multiple. Always test the GCD result and its divisors.
The Kasiski test fails on very short ciphertexts (<200 characters) because there aren't enough repeated substrings. It also fails if the key is longer than any repeated plaintext fragment. That's why we use it as an estimator, not a guarantee, and confirm with IC.
# io.thecodeforge.crypto.analysis.KasiskiExamination from math import gcd from functools import reduce from collections import Counter def kasiski_distances(ciphertext: str, min_len: int = 3, max_len: int = 5) -> list: """Find all distances between repeated ciphertext substrings. Returns a list of (substring, position1, position2, distance) tuples so you can inspect exactly what the test found. """ ct = ''.join(c for c in ciphertext.upper() if c.isalpha()) repeats = [] for length in range(min_len, max_len + 1): seen = {} # substring -> first position for i in range(len(ct) - length + 1): substr = ct[i:i + length] if substr in seen: distance = i - seen[substr] repeats.append((substr, seen[substr], i, distance)) else: seen[substr] = i return repeats def kasiski_key_length(ciphertext: str, min_len: int = 3) -> int: """Estimate Vigenère key length using the Kasiski examination. Strategy: collect all distances between repeated substrings, factor each distance, and return the most common factor > 1. """ ct = ''.join(c for c in ciphertext.upper() if c.isalpha()) distances = [] for length in range(min_len, min_len + 5): seen = {} for i in range(len(ct) - length + 1): substr = ct[i:i + length] if substr in seen: distances.append(i - seen[substr]) else: seen[substr] = i if not distances: return 1 # Factor each distance and count factor frequencies factor_counts = Counter() for d in distances: for f in range(2, d + 1): if d % f == 0: factor_counts[f] += 1 # Return the most common factor most_common_factor = factor_counts.most_common(1)[0][0] return most_common_factor # --- Demonstrate with a real example --- plaintext = ( 'THEQUICKBROWNFOXJUMPSOVERTHELAZYDOG' 'THEQUICKBROWNFOXJUMPSOVERTHELAZYDOG' 'THEQUICKBROWNFOXJUMPSOVERTHELAZYDOG' ) key = 'CRYPTO' ct = vigenere_encrypt(plaintext, key) print(f"Ciphertext (first 60 chars): {ct[:60]}...") print(f"True key length: {len(key)}") # Show repeated substrings repeats = kasiski_distances(ct, min_len=3) print(f"\nFound {len(repeats)} repeated substring pairs (showing first 10):") for substr, pos1, pos2, dist in repeats[:10]: print(f" '{substr}' at positions {pos1} and {pos2}, distance = {dist}") # Estimate key length estimated = kasiski_key_length(ct) print(f"\nKasiski estimated key length: {estimated}") print(f"Divisors to also check: {[d for d in range(2, estimated+1) if estimated % d == 0]}") # Show distances and their GCD distances_only = [r[3] for r in repeats] if distances_only: overall_gcd = reduce(gcd, distances_only) print(f"GCD of all distances: {overall_gcd}")
Index of Coincidence — The Statistical Confirmation
The Index of Coincidence (IC), introduced by William Friedman in 1920, measures how 'clustered' the letter distribution is in a text. It's the probability that two randomly chosen letters from the text are identical.
The formula: IC = Σ f_i(f_i - 1) / (N(N - 1)) where f_i is the count of the i-th letter and N is the total number of letters.
Why does this work? In English, common letters like E, T, A dominate. If you pick two random positions, there's a decent chance both land on E. The IC for English is about 0.065. For random text (uniform distribution), each letter appears equally often, so IC ≈ 1/26 ≈ 0.038.
Here's the key insight for Vigenère: if you split the ciphertext into k groups (every k-th letter), and k equals the true key length, each group is a simple Caesar cipher. Caesar preserves letter frequencies — it just shifts them. So each group should show English-level IC (≈0.065). If k is wrong, the groups mix letters from different Caesar shifts, and the IC drops toward 0.038.
Friedman's formula for estimating key length directly from IC is: key_length ≈ (0.0265) / ((IC_ciphertext - 0.0385)). This gives you a quick estimate without the Kasiski test at all. In practice, I use both: Kasiski for a candidate range, IC sweep to confirm.
I once worked on a steganography detection project where we used IC to distinguish encrypted payloads from compressed data inside JPEG files. Encrypted data has IC ≈ 0.038 (essentially random), while compressed-but-not-encrypted data has slightly higher IC due to format headers. It's a surprisingly versatile statistic.
Another use case: detecting language in ciphertexts. Different languages have different IC values — Latin-based languages range from 0.065 to 0.078. If the IC of your ciphertext is closer to 0.038, it's either random or strongly encrypted. This is how cryptanalysts can tell if a message is in English, German, or just noise.
# io.thecodeforge.crypto.analysis.IndexOfCoincidence from collections import Counter def index_of_coincidence(text: str) -> float: """Compute the Index of Coincidence for the given text. IC = sum(f_i * (f_i - 1)) / (N * (N - 1)) where f_i is frequency of each letter, N is total letter count. English text: IC ≈ 0.065 Random text: IC ≈ 0.038 (1/26) """ letters = [c for c in text.upper() if c.isalpha()] n = len(letters) if n < 2: return 0.0 counts = Counter(letters) return sum(c * (c - 1) for c in counts.values()) / (n * (n - 1)) def friedman_key_estimate(ciphertext: str) -> float: """Estimate key length using Friedman's IC-based formula. key_length ≈ (kappa_p - kappa_r) / (IC_ct - kappa_r) where kappa_p = 0.065 (English IC) and kappa_r = 0.0385 (random IC) """ ic = index_of_coincidence(ciphertext) kappa_p = 0.065 # IC for English plaintext kappa_r = 0.0385 # IC for random/uniform distribution if abs(ic - kappa_r) < 0.001: return float('inf') # IC too close to random return (kappa_p - kappa_r) / (ic - kappa_r) def ic_sweep(ciphertext: str, max_key_len: int = 20) -> list: """Test every possible key length and return average IC per stream. For each candidate key length k, split ciphertext into k streams (every k-th letter), compute IC for each stream, and average. The key length with the highest average IC is most likely correct. """ ct = ''.join(c for c in ciphertext.upper() if c.isalpha()) results = [] for k in range(1, max_key_len + 1): stream_ics = [] for offset in range(k): stream = ct[offset::k] if len(stream) >= 2: stream_ics.append(index_of_coincidence(stream)) avg_ic = sum(stream_ics) / len(stream_ics) if stream_ics else 0 results.append((k, avg_ic)) return results # --- Demonstrate IC on different text types --- english_text = "THEQUICKBROWNFOXJUMPSOVERTHELAZYDOG" random_text = "XQJKVBZPYGWMFUCLDHRSNIOAET" # shuffled alphabet, no repetition print("Index of Coincidence for different text types:") print(f" English text: IC = {index_of_coincidence(english_text):.4f} (expect ~0.065)") print(f" Random/shuffled: IC = {index_of_coincidence(random_text):.4f} (expect ~0.038)") # Vigenère ciphertext should have intermediate IC long_english = "THEQUICKBROWNFOXJUMPSOVERTHELAZYDOG" * 10 vigenere_ct = vigenere_encrypt(long_english, "SECRET") print(f" Vigenère (6-key): IC = {index_of_coincidence(vigenere_ct):.4f} (between 0.038 and 0.065)") # Friedman estimate friedman_est = friedman_key_estimate(vigenere_ct) print(f"\nFriedman key length estimate: {friedman_est:.1f} (true: 6)") # IC sweep to find best key length print("\nIC sweep (average IC per stream for each candidate key length):") sweep_results = ic_sweep(vigenere_ct, max_key_len=12) for k, avg_ic in sweep_results: bar = '█' * int(avg_ic * 500) marker = ' ← best' if avg_ic == max(r[1] for r in sweep_results) else '' print(f" k={k:>2}: IC={avg_ic:.4f} {bar}{marker}")
Full Cryptanalysis Pipeline — From Ciphertext to Plaintext
Now let's put it all together. Given a Vigenère ciphertext with an unknown key, here's the complete attack:
Step 1: Estimate key length using Kasiski examination and IC sweep. Take the candidate that both methods agree on.
Step 2: Split the ciphertext into k groups (every k-th letter). Each group is a Caesar cipher.
Step 3: For each group, compute letter frequencies and compare against expected English frequencies. The shift that produces the best match is the key letter for that position.
Step 4: Assemble the key letters into the full keyword. Decrypt. Verify by checking if the output reads as English.
The chi-squared statistic is the standard way to measure frequency match: χ² = Σ (observed - expected)² / expected. Lower χ² means better match. For each possible shift (0-25), rotate the observed frequencies and compute χ² against English letter frequencies. The shift with the lowest χ² is the key letter.
This is the attack that broke Vigenère for 300 years. It works because English letter frequencies are dramatically non-uniform — E appears about 12.7% of the time while Z appears 0.07%. No amount of shifting hides this distribution; it just permutes which ciphertext letter carries the E-frequency.
In a CTF competition I coached, our team cracked a 120-character Vigenère ciphertext with an 8-letter key in under 30 seconds using exactly this pipeline. The key was 'BLETCHLEY' — a nice historical nod from the challenge author.
A note on implementation: the chi-squared calculation must use expected frequencies for the plaintext language (English). If you're analysing a different language, adjust the expected frequencies. For unknown languages, you can use the IC to identify the language first.
# io.thecodeforge.crypto.analysis.VigenereCryptanalysis from collections import Counter import string # Expected English letter frequencies ENGLISH_FREQ = { 'A': 0.08167, 'B': 0.01492, 'C': 0.02782, 'D': 0.04253, 'E': 0.12702, 'F': 0.02228, 'G': 0.02015, 'H': 0.06094, 'I': 0.06966, 'J': 0.00153, 'K': 0.00772, 'L': 0.04025, 'M': 0.02406, 'N': 0.06749, 'O': 0.07507, 'P': 0.01929, 'Q': 0.00095, 'R': 0.05987, 'S': 0.06327, 'T': 0.09056, 'U': 0.02758, 'V': 0.00978, 'W': 0.02360, 'X': 0.00150, 'Y': 0.01974, 'Z': 0.00074 } def chi_squared(observed_counts: Counter, total: int, shift: int) -> float: """Compute chi-squared statistic for a given Caesar shift. Rotates observed frequencies by 'shift' positions and compares against expected English frequencies. Lower is better — means the shifted distribution matches English. """ chi2 = 0.0 for i in range(26): # Which ciphertext letter maps to plaintext letter i after shifting? ct_letter = chr((i + shift) % 26 + ord('A')) observed = observed_counts.get(ct_letter, 0) expected = ENGLISH_FREQ[chr(i + ord('A'))] * total if expected > 0: chi2 += (observed - expected) ** 2 / expected return chi2 def crack_caesar_stream(stream: str) -> tuple: """Find the best Caesar shift for a single stream of letters. Returns (shift, key_letter, chi_squared_value). """ counts = Counter(stream.upper()) total = len(stream) best_shift = 0 best_chi2 = float('inf') for shift in range(26): chi2 = chi_squared(counts, total, shift) if chi2 < best_chi2: best_chi2 = chi2 best_shift = shift key_letter = chr(best_shift + ord('A')) return best_shift, key_letter, best_chi2 def crack_vigenere(ciphertext: str, key_length: int) -> str: """Full Vigenère cryptanalysis: given ciphertext and key length, recover the key using per-stream frequency analysis. """ ct = ''.join(c for c in ciphertext.upper() if c.isalpha()) key_chars = [] print(f" {'Stream':>6} {'Key':>3} {'Shift':>5} {'Chi2':>8} {'Sample decrypted'}") print(f" {'------':>6} {'---':>3} {'-----':>5} {'--------':>8} {'----------------'}") for i in range(key_length): stream = ct[i::key_length] shift, key_letter, chi2 = crack_caesar_stream(stream) key_chars.append(key_letter) # Show first 20 decrypted chars of this stream sample = ''.join( chr((ord(c) - ord('A') - shift) % 26 + ord('A')) for c in stream[:20] ) print(f" {i:>6} {key_letter:>3} {shift:>5} {chi2:>8.2f} {sample}") recovered_key = ''.join(key_chars) return recovered_key # --- Full attack demonstration --- original_plaintext = ( "CRYPTOGRAPHYISTHEPRACTICEANDSTUDYOFTECHNIQUESFOR" "PROTECTINGINFORMATIONINPRESENCEOFADVERSARIES" ) secret_key = "BLETCHLEY" print(f"Original plaintext: {original_plaintext[:50]}...") print(f"Secret key: {secret_key}") print(f"Key length: {len(secret_key)}") ciphertext = vigenere_encrypt(original_plaintext, secret_key) print(f"Ciphertext: {ciphertext[:50]}...") print() # Step 1: Estimate key length print("Step 1: Key length estimation") friedman_est = friedman_key_estimate(ciphertext) print(f" Friedman estimate: {friedman_est:.1f}") sweep = ic_sweep(ciphertext, max_key_len=15) best_k = max(sweep, key=lambda x: x[1]) print(f" IC sweep best: k={best_k[0]} (avg IC={best_k[1]:.4f})") print() # Step 2-3: Crack the key print(f"Step 2-3: Per-stream frequency analysis (key length = {best_k[0]})") recovered_key = crack_vigenere(ciphertext, best_k[0]) print(f"\nRecovered key: {recovered_key}") print() # Step 4: Decrypt and verify print("Step 4: Decryption with recovered key") decrypted = vigenere_decrypt(ciphertext, recovered_key) print(f"Decrypted: {decrypted[:60]}...") print(f"Match: {decrypted == original_plaintext}")
Why Vigenère Still Fails — And What Fixes It
Vigenère fails for one reason: key repetition. Every attack — Kasiski, IC, Friedman's formula — exploits the fact that the key cycles. Once you know the cycle length, each position is just a Caesar cipher, and Caesar is trivially broken.
The theoretical fix is the One-Time Pad (OTP), proven unbreakable by Shannon in 1949. An OTP uses a truly random key that is (1) as long as the message, (2) never reused, and (3) kept completely secret. With these conditions met, the ciphertext contains zero information about the plaintext — every possible plaintext of the same length is equally likely. The problem: key distribution. You need to securely share a key as long as every message you'll ever send. That's impractical at scale.
The historical lesson is brutal. The Lorenz cipher — used by Hitler's High Command in WWII — was essentially a mechanical Vigenère with a much longer, machine-generated key. It was stronger than manual Vigenère, but British codebreakers at Bletchley Park (led by Bill Tutte and powered by Tommy Flowers' Colossus computer) broke it because German operators occasionally reused key settings — reintroducing the exact repetition that Vigenère's security depends on eliminating.
Modern stream ciphers (ChaCha20, AES-CTR) solve this pragmatically. They use a short key (128 or 256 bits) plus a nonce to generate a cryptographically secure pseudorandom keystream that is effectively non-repeating for any practical message length. ChaCha20 with a 256-bit key and 96-bit nonce produces 2^64 bytes of keystream before any repetition — that's 18 exabytes. You'd need to encrypt the entire internet multiple times over before the keystream cycles.
The progression is clear: Caesar (1 alphabet, trivially broken) → Vigenère (26 alphabets, broken by Kasiski) → One-Time Pad (infinite alphabets, unbreakable but impractical) → Modern stream ciphers (practical infinite alphabets via CSPRNG). Each step addresses the weakness of the previous one.
There's also the autokey cipher (Vigenère's actual invention): instead of repeating the keyword, you append the plaintext itself to the key after the initial keyword. This eliminates repetition but introduces correlation between key and plaintext, which is exploitable with enough ciphertext. It's stronger than standard Vigenère but still not secure by modern standards.
# io.thecodeforge.crypto.comparison.CipherComparison import os import hashlib def one_time_pad_encrypt(plaintext: str, key: bytes) -> str: """Encrypt using XOR with a truly random key (One-Time Pad principle). Key must be at least as long as the plaintext. This is the theoretical 'unbreakable' cipher — IF the key is truly random, never reused, and kept secret. """ pt_bytes = plaintext.encode('utf-8') if len(key) < len(pt_bytes): raise ValueError("Key must be at least as long as plaintext") ct_bytes = bytes(p ^ k for p, k in zip(pt_bytes, key[:len(pt_bytes)])) return ct_bytes.hex() def one_time_pad_decrypt(ciphertext_hex: str, key: bytes) -> str: """Decrypt One-Time Pad ciphertext.""" ct_bytes = bytes.fromhex(ciphertext_hex) pt_bytes = bytes(c ^ k for c, k in zip(ct_bytes, key[:len(ct_bytes)])) return pt_bytes.decode('utf-8') def simple_stream_cipher_encrypt(plaintext: str, key: str) -> str: """Simplified stream cipher using SHA-256 as CSPRNG. This demonstrates the modern approach: use a short key to seed a cryptographically secure pseudorandom generator that produces an effectively infinite non-repeating keystream. NOTE: This is for educational purposes — use ChaCha20 or AES-CTR from the 'cryptography' library for real applications. """ pt_bytes = plaintext.encode('utf-8') keystream = b'' block = key.encode('utf-8') while len(keystream) < len(pt_bytes): block = hashlib.sha256(block).digest() keystream += block ct_bytes = bytes(p ^ k for p, k in zip(pt_bytes, keystream[:len(pt_bytes)])) return ct_bytes.hex() def simple_stream_cipher_decrypt(ciphertext_hex: str, key: str) -> str: """Decrypt simple stream cipher ciphertext.""" ct_bytes = bytes.fromhex(ciphertext_hex) keystream = b'' block = key.encode('utf-8') while len(keystream) < len(ct_bytes): block = hashlib.sha256(block).digest() keystream += block pt_bytes = bytes(c ^ k for c, k in zip(ct_bytes, keystream[:len(ct_bytes)])) return pt_bytes.decode('utf-8') # --- Compare all three approaches --- message = "MEETMEATTHEOLDCABINATMIDNIGHTBRINGTHEPLANS" print("=" * 60) print("CIPHER COMPARISON") print("=" * 60) # 1. Vigenère vig_key = "SECRET" vig_ct = vigenere_encrypt(message, vig_key) print(f"\n1. VIGENÈRE (key='{vig_key}', length={len(vig_key)})") print(f" Ciphertext: {vig_ct}") print(f" Key repeats every {len(vig_key)} characters") print(f" Vulnerable to: Kasiski test, IC analysis, frequency analysis") # 2. One-Time Pad otp_key = os.urandom(len(message)) # truly random, same length as message otp_ct = one_time_pad_encrypt(message, otp_key) print(f"\n2. ONE-TIME PAD (key length={len(otp_key)} bytes, truly random)") print(f" Ciphertext: {otp_ct[:60]}...") print(f" Key never repeats — unbreakable if key is kept secret") print(f" Vulnerable to: nothing (mathematically proven secure)") print(f" Practical problem: key distribution at scale") # 3. Stream cipher stream_key = "short_secret_key_16" # 18 characters → 144 bits of entropy stream_ct = simple_stream_cipher_encrypt(message, stream_key) print(f"\n3. STREAM CIPHER (key='{stream_key}', length={len(stream_key)} chars)") print(f" Ciphertext: {stream_ct[:60]}...") print(f" Key is short but generates infinite non-repeating keystream") print(f" Vulnerable to: nothing practical (CSPRNG is computationally secure)") # Show decryption works stream_dec = simple_stream_cipher_decrypt(stream_ct, stream_key) print(f" Decrypted: {stream_dec[:30]}... matches? {stream_dec == message}") # Show key length comparison print(f"\n{'Cipher':<20} {'Key length':<15} {'Repeats?':<12} {'Broken?'}") print(f"{'-'*20} {'-'*15} {'-'*12} {'-'*12}") print(f"{'Vigenère':<20} {str(len(vig_key))+' chars':<15} {'Yes':<12} {'Yes (Kasiski)'}") print(f"{'One-Time Pad':<20} {str(len(otp_key))+' bytes':<15} {'No':<12} {'No (proven)'}") print(f"{'Stream Cipher':<20} {str(len(stream_key))+' chars':<15} {'No (practical)':<12} {'No (practical)'}")
Common Mistakes When Implementing or Attacking Vigenère
After years of teaching this material and reviewing student implementations, I see the same errors repeatedly. Avoiding these will save you hours of debugging.
The most dangerous mistake is using Vigenère for anything real. I've seen hobby projects and even some 'security tools' on GitHub that implement Vigenère as if it's a viable encryption scheme. It's not. Vigenère was broken in 1863. It's a teaching tool, not a security tool. If you need encryption, use AES-GCM or ChaCha20-Poly1305 from a proper cryptographic library.
On the implementation side: always strip non-alphabetic characters before running cryptanalysis, but preserve them for display. The Kasiski test and IC analysis only work on pure letter sequences. Mixing in spaces and punctuation corrupts the statistics.
On the analysis side: short ciphertexts (under 100 characters) make frequency analysis unreliable. The chi-squared test needs enough samples per stream to converge — with a key length of 10 and a 50-character message, each stream has only 5 letters. You can't extract meaningful frequency distributions from 5 samples.
Another trap: assuming the key is English. CTF challenges sometimes use random letter sequences or non-English words. If your chi-squared analysis gives you a key that doesn't form a word, try decrypting anyway — the key might be a random string like 'XQJK'.
A subtle implementation bug: case handling. You must convert both plaintext and key to the same case before modular arithmetic. If you keep the original case, 'a' (97) and 'A' (65) produce different ASCII values, shifting incorrectly. Always normalise to uppercase before shifting, then reconstruct case after (or ignore case entirely for classical ciphers).
Another one: the key index reset. When decrypting multiple ciphertexts with the same key, the key index must be reset for each new ciphertext. If you forget, the key drifts and decryption corrupts.
# io.thecodeforge.crypto.pitfalls.VigenerePitfalls def demonstrate_key_index_bug(): """Show the most common implementation bug: key index drift.""" print("=== Bug: Key Index Drifts on Non-Alphabetic Characters ===") print() def buggy_encrypt(text, key): """WRONG: increments key index for every character.""" key = key.upper() result = [] ki = 0 for ch in text: if ch.isalpha(): base = ord('A') if ch.isupper() else ord('a') shift = ord(key[ki % len(key)]) - ord('A') result.append(chr((ord(ch) - base + shift) % 26 + base)) else: result.append(ch) ki += 1 # BUG: increments even for spaces! return ''.join(result) def correct_encrypt(text, key): """RIGHT: only increments key index for alphabetic characters.""" key = key.upper() result = [] ki = 0 for ch in text: if ch.isalpha(): base = ord('A') if ch.isupper() else ord('a') shift = ord(key[ki % len(key)]) - ord('A') result.append(chr((ord(ch) - base + shift) % 26 + base)) ki += 1 # CORRECT: only increments for letters else: result.append(ch) return ''.join(result) plaintext = 'HELLO WORLD' key = 'KEY' buggy_ct = buggy_encrypt(plaintext, key) correct_ct = correct_encrypt(plaintext, key) print(f" Plaintext: '{plaintext}'") print(f" Key: '{key}'") print(f" Buggy: '{buggy_ct}'") print(f" Correct: '{correct_ct}'") print(f" Match: {buggy_ct == correct_ct}") print() print(" The buggy version shifts 'WORLD' using key positions 4,5,6") print(" instead of 3,4,5 — the space consumed a key position.") print(" Decryption with the correct function produces garbage.") def demonstrate_short_text_problem(): """Show why short texts break frequency analysis.""" print("\n=== Problem: Short Texts Have Unreliable Statistics ===") print() short_text = 'ATTACK' long_text = 'THEQUICKBROWNFOXJUMPSOVERTHELAZYDOG' * 20 key = 'AB' ct_short = vigenere_encrypt(short_text, key) ct_long = vigenere_encrypt(long_text, key) # Each stream for key length 2 short_stream_0 = ct_short[0::2] # positions 0,2,4 short_stream_1 = ct_short[1::2] # positions 1,3,5 long_stream_0 = ct_long[0::2] long_stream_1 = ct_long[1::2] print(f" Short text ({len(short_text)} chars): '{short_text}'") print(f" Stream 0: '{short_stream_0}' ({len(short_stream_0)} letters — too few for frequency analysis)") print(f" Stream 1: '{short_stream_1}' ({len(short_stream_1)} letters — too few for frequency analysis)") print(f" IC stream 0: {index_of_coincidence(short_stream_0):.3f} (meaningless with <20 letters)") print() print(f" Long text ({len(long_text)} chars)") print(f" Stream 0: {len(long_stream_0)} letters") print(f" Stream 1: {len(long_stream_1)} letters") print(f" IC stream 0: {index_of_coincidence(long_stream_0):.3f} (reliable)") print() print(" Rule of thumb: need 100+ characters per stream for reliable IC.") print(" With key length k, total ciphertext should be at least 100*k characters.") def demonstrate_case_handling_bug(): """Show bug with mixed case.""" print("\n=== Bug: Mixed Case Causing Incorrect Shifts ===") print() def buggy_case_encrypt(text, key): """WRONG: doesn't normalise case.""" result = [] ki = 0 for ch in text: if ch.isalpha(): # BUG: uses ASCII values directly — 'a' (97) vs 'A' (65) cause different shifts shift = ord(key[ki % len(key)].upper()) - ord('A') ct_val = ord(ch) + shift # This is completely broken for lowercase plaintext result.append(chr(ct_val)) ki += 1 else: result.append(ch) return ''.join(result) def correct_case_encrypt(text, key): """RIGHT: normalise to uppercase before arithmetic.""" key = key.upper() result = [] ki = 0 for ch in text: if ch.isalpha(): base = ord('A') if ch.isupper() else ord('a') shift = ord(key[ki % len(key)]) - ord('A') result.append(chr((ord(ch) - base + shift) % 26 + base)) ki += 1 else: result.append(ch) return ''.join(result) plaintext = 'AttackAtDawn' key = 'LEMON' buggy_ct = buggy_case_encrypt(plaintext, key) correct_ct = correct_case_encrypt(plaintext, key) print(f" Plaintext: '{plaintext}'") print(f" Key: '{key}'") print(f" Buggy (no normalisation): '{buggy_ct}'") print(f" Correct: '{correct_ct}'") print(f" Expected: LXFOPVEFRNHR") demonstrate_key_index_bug() demonstrate_short_text_problem() demonstrate_case_handling_bug()
ch.isalpha() is true.Why Brute-Force Won't Work on Vigenère (And What Actually Does)
Most juniors assume Vigenère falls to brute-force like Caesar. It won't. A 5-letter key gives you 26^5 = 11.8 million possible keys. That's trivial for a modern CPU, but the ciphertext won't tell you which key is correct because every key produces valid-looking plaintext. You're just shifting blame around the alphabet.
Brute-force fails because Vigenère isn't a single substitution — it's 26 different substitution alphabets stacked on a repeating key. Each position in the ciphertext uses a different Caesar shift, so the letter-frequency distribution of the plaintext gets flattened across the ciphertext. You can't just try keys and check if the output looks like English. Every key produces English-like letter distributions because you're always dealing with a permutation of the original text.
The real attack is structural: find the key length first, then crack each position independently. That's why Kasiski and Index of Coincidence matter. They reduce the problem from 26^n to n independent 26-way choices. Don't waste cycles brute-forcing. Use statistics.
// io.thecodeforge — dsa tutorial public class VigenereBruteForceProof { public static void main(String[] args) { String ciphertext = "GCYCZFMLYLEIM"; // Attempting brute-force with a 5-letter key char[] key = new char[5]; for (int i = 0; i < Math.pow(26, 5); i++) { int temp = i; for (int j = 0; j < 5; j++) { key[j] = (char) ('A' + (temp % 26)); temp /= 26; } String candidate = decrypt(ciphertext, new String(key)); // Every candidate looks like valid text — no way to filter } System.out.println("All 11.8 million keys produce valid-looking plaintext."); } static String decrypt(String ct, String key) { // Standard Vigenère decryption StringBuilder result = new StringBuilder(); for (int i = 0; i < ct.length(); i++) { int shift = key.charAt(i % key.length()) - 'A'; char decrypted = (char) ('A' + (ct.charAt(i) - 'A' - shift + 26) % 26); result.append(decrypted); } return result.toString(); } }
Vigenère in the Wild — Why Modern Crypto Laughs at It
You won't find Vigenère protecting anything real today. Not because it can't be implemented — it can, in 10 lines of code — but because it has zero forward secrecy and zero resistance to known-plaintext attacks. If an attacker gets one plaintext-ciphertext pair, they recover the entire key immediately. That's game over for any future messages using the same key.
Modern symmetric ciphers like AES handle this via key schedules and round functions that make the relationship between plaintext and key non-linear. Vigenère is linear: each character is just plaintext + key mod 26. That's the equivalent of using XOR with a repeating key — exactly what CTR mode does, but without the nonce or block cipher underneath.
The real lesson isn't that Vigenère is weak. It's that all historical ciphers fail because they lack diffusion and confusion. Shannon laid this out in 1949: you need both properties to resist statistical attacks. Vigenère gives you confusion (each position uses a different alphabet) but zero diffusion (each character encrypts independently). That's why it's educational, not operational.
Use Vigenère to understand why modern ciphers have block sizes, rounds, and non-linear substitution boxes. Then never write a production system that uses it.
// io.thecodeforge — dsa tutorial public class VigenereKnownPlaintextAttack { public static void main(String[] args) { String knownPlaintext = "ATTACKATDAWN"; String ciphertext = "GCYCZFMLYLEIM"; // Recover key with known plaintext attack StringBuilder key = new StringBuilder(); for (int i = 0; i < knownPlaintext.length(); i++) { int shift = (ciphertext.charAt(i) - knownPlaintext.charAt(i) + 26) % 26; key.append((char) ('A' + shift)); } // Key repeats after 5 characters String fullKey = key.toString(); System.out.println("Recovered key: " + fullKey.substring(0, 5)); // Now decrypt any future message with this key String futureCipher = "QKPLSXRX"; // Some other message System.out.println("Future message decrypted: " + decrypt(futureCipher, "AYUSH")); } static String decrypt(String ct, String key) { StringBuilder result = new StringBuilder(); for (int i = 0; i < ct.length(); i++) { int shift = key.charAt(i % key.length()) - 'A'; char decrypted = (char) ('A' + (ct.charAt(i) - 'A' - shift + 26) % 26); result.append(decrypted); } return result.toString(); } }
The WEP WiFi Break: When Key Reuse Kills 64-Bit Encryption
- Key reuse is the single most devastating vulnerability in any cipher. Vigenère repeats its key. WEP repeated its keystream. Both were catastrophically broken.
- Never design a protocol where the same key material is used for multiple messages. Use per-message nonces or IVs, and make sure they're long enough (96+ bits).
- The Kasiski test finds repetition by looking for identical ciphertext blocks. Modern side-channel attacks do the same thing — any pattern in the output is dangerous.
- If your encryption scheme has a period, an attacker will find it. Vigenère's period is the key length. WEP's period is 2^24 packets. Both were too short.
ciphertext = ''.join(c for c in ct if c.isalpha()) before analysis.(ciphertext_letter - key_letter) mod 26, not addition.kasiski_key_length(ciphertext)ic_sweep(ciphertext, max_key_len=20)crack_vigenere(ciphertext, key_length)vigenere_decrypt(ciphertext, recovered_key)from collections import Counter; cnt = Counter(stream); print(cnt.most_common(5))Expected: E, T, A, O, I should be top 5. If not, your shift is likely wrong.print(f'Need at least {estimated_key_length * 100} characters for reliable IC')print(f'Current length: {len(stripped)}')| Cipher | Type | Key Structure | Resists Frequency Analysis? | Broken By | Practical? | Historical Period |
|---|---|---|---|---|---|---|
| Caesar Cipher | Monoalphabetic substitution | Single number (0-25) | No — E, T, A peaks are preserved | Letter frequency counting | No | ~50 BC (Julius Caesar) |
| Vigenère Cipher (Bellaso) | Polyalphabetic substitution | Repeating keyword (much shorter than message) | Partially — flattened but exploitable via periodicity | Kasiski test + IC + frequency analysis | No | 1553 (Bellaso) / 1586 (Vigenère) |
| One-Time Pad | Polyalphabetic with truly random key | Random key = message length | Yes — completely | Nothing (mathematically proven by Shannon 1949) | No (key distribution impossible at scale) | 1917 (Vernam) / 1949 (Shannon proof) |
| Autokey Cipher | Polyalphabetic with key = keyword + plaintext | Keyword + plaintext appended (no repetition) | Yes against Kasiski, but still breakable | Bootstrapping from guessed plaintext | No | 1586 (Vigenère's actual invention) |
| WEP (RC4 + IV) | Stream cipher with repeating IV | 24-bit IV + fixed key (effectively repeating keystream) | No — IV reuse leads to XOR cancellation attack | Fluhrer-Mantin-Shamir (FMS) attack | No (deprecated 2004) | 1999-2004 |
| ChaCha20 / AES-CTR | Stream cipher / block cipher in CTR mode | 256-bit key + 96-bit nonce (effectively infinite) | Yes — uniformly distributed keystream | No known practical attack | Yes — industry standard | 2008 (ChaCha20) / 2001 (AES-CTR) |
| File | Command / Code | Purpose |
|---|---|---|
| io | def vigenere_encrypt(text: str, key: str) -> str: | How Vigenère Actually Works |
| io | from math import gcd | Kasiski Examination |
| io | from collections import Counter | Index of Coincidence |
| io | from collections import Counter | Full Cryptanalysis Pipeline |
| io | def one_time_pad_encrypt(plaintext: str, key: bytes) -> str: | Why Vigenère Still Fails |
| io | def demonstrate_key_index_bug(): | Common Mistakes When Implementing or Attacking Vigenère |
| VigenereBruteForceProof.java | public class VigenereBruteForceProof { | Why Brute-Force Won't Work on Vigenère (And What Actually Do |
| VigenereKnownPlaintextAttack.java | public class VigenereKnownPlaintextAttack { | Vigenère in the Wild |
Key takeaways
Common mistakes to avoid
6 patternsImplementing Vigenère with key index incrementing on every character (including spaces/punctuation)
ch.isalpha() is true. Non-letters pass through unchanged without consuming a key position.Not normalising case before arithmetic
base = ord('A') if ch.isupper() else ord('a') to preserve case after encryption, or just uppercase everything and ignore case.Assuming the key must form an English word
Using Vigenère for actual security in any production system
Running cryptanalysis on ciphertext with spaces and punctuation present
ciphertext = ''.join(c for c in ct if c.isalpha()). Then run Kasiski/IC. Preserve the original for final output formatting.Assuming the Kasiski-estimated key length is exact and not checking divisors
Practice These on LeetCode
Interview Questions on This Topic
How does the Kasiski test determine the key length of a Vigenère cipher? Walk through the logic step by step, including why distances between repeated substrings are multiples of the key length.
What is the Index of Coincidence, what are the expected values for English text and random text, and how do you use it to confirm the key length of a Vigenère cipher?
Why does Vigenère fail despite using 26 different Caesar shifts? What is the fundamental structural weakness that all attacks exploit?
Explain the full cryptanalysis pipeline for breaking a Vigenère ciphertext with an unknown key. What statistical tools are used at each stage?
How does a modern stream cipher like ChaCha20 relate to Vigenère conceptually? What specific property does it share, and what does it do differently to achieve security?
Frequently Asked Questions
No. The cipher was first described by Giovan Battista Bellaso in 1553. Blaise de Vigenère published a similar (but actually stronger — the autokey variant) cipher in 1586. The misattribution happened in the 19th century when a librarian incorrectly credited Vigenère with Bellaso's design, and the name stuck. So the cipher we call 'Vigenère' is actually Bellaso's cipher, and Vigenère's real contribution (the autokey cipher) is largely forgotten.
With a modern computer, seconds. The Kasiski test and IC sweep run in milliseconds on any message longer than 100 characters. The per-stream chi-squared analysis takes another few milliseconds. I've built a complete crack tool (ciphertext in, plaintext + key out) that runs in under 100ms for messages up to 10,000 characters with key lengths up to 20. The bottleneck isn't computation — it's having enough ciphertext. Messages under 100 characters may not have sufficient statistical signal for reliable key recovery.
If the key is as long as the message, never reused, and truly random, you've created a One-Time Pad — which is theoretically unbreakable. But that's no longer Vigenère; it's an OTP. Vigenère's defining property is the repeating key. If you remove the repetition, you've removed the vulnerability, but you've also created the key distribution problem: how do you securely share a key as long as every message? That's why modern ciphers use short keys with CSPRNG keystreams — they get OTP-like security without the key distribution nightmare.
Roughly 100 characters per stream for reliable IC, so 100 × key_length characters minimum. For a key of length 6, you need about 600 characters. With less, the letter frequencies in each stream don't converge to the expected English distribution, and chi-squared analysis gives unreliable results. In CTF competitions, I've broken Vigenère with as few as 200 characters and a key length of 5, but it required manual verification of multiple candidate keys.
Conceptually, yes. Enigma is a polyalphabetic cipher like Vigenère, but far more complex. Where Vigenère uses 26 fixed Caesar alphabets selected by a repeating keyword, Enigma uses a rotor mechanism that produces a different substitution alphabet for each keypress — and the rotors step with each character, so the 'key' effectively never repeats in the same way. Enigma's additional complexity (plugboard, rotor order, ring settings, reflector) made it much harder to break than Vigenère, but the same fundamental insight — finding patterns in the key schedule — is what Turing's team exploited at Bletchley Park.
The principle generalises: modular addition of a repeating key works on any alphabet size. For numbers 0-9, use mod 10 instead of mod 26. For binary data, XOR with a repeating key is the binary analogue of Vigenère — and it's equally vulnerable to the same attacks (Kasiski equivalent is finding repeated XOR patterns). This is essentially how WEP's RC4 implementation was broken: the IV (initialisation vector) was too short, causing key reuse — the same fundamental flaw as Vigenère's repeating keyword.
The Tabula Recta (Vigenère table) is a 26×26 grid where each row is the alphabet shifted by the row letter's position. To encrypt, find the plaintext letter in the top row, the key letter in the left column, and read the ciphertext letter at their intersection. It's a handy visual tool before computers, but in practice, modular arithmetic is much faster. The table is still useful for manually explaining Vigenère without modulo operations. Historically, cryptographers used physical tables like this for encryption by hand.
20+ years shipping performance-critical code where algorithms decide the bill. Lessons pulled from things that broke in production.
That's Cryptography. Mark it forged?
11 min read · try the examples if you haven't