Breaking a Simplified Enigma Machine
The real wartime Enigma resisted brute force because of two unknowns stacked on top of its rotor positions: which three rotors were chosen and in what order, and how the plugboard was wired. Together these push the key space past 10^20. Strip those two unknowns away and only 17,576 rotor positions are left, small enough for a modern computer to brute-force outright.
Interactive Enigma Breaker
🔐 Enigma Breaker (Simplified)
Breaking a Simplified Enigma: What’s Left Once the Hard Parts Are Assumed Away
Introduction
The Enigma machine produces a different substitution alphabet for nearly every keystroke, which is why it held up against naive frequency analysis for as long as it did. But the real historical break, carried out at Bletchley Park under Alan Turing, didn’t attack Enigma’s full key space head-on. It couldn’t. The wartime Enigma’s key was really three separate unknowns multiplied together: which three rotors were chosen (and in what left-to-right order) out of the available set, where each of those rotors started, and how up to ten pairs of letters were cross-wired on the plugboard. The plugboard alone contributes roughly 150 trillion possibilities. Multiplied by 60 possible rotor orders and 17,576 rotor starting positions, the full key space comes out well past 10^20, hopeless for brute force, then or now. This post builds a simplified Enigma breaker that assumes the rotor order and plugboard are already known (a fixed rotor I–II–III with no plugboard), leaving only the 17,576 rotor starting positions to search. That’s a key space small enough that “try every key and score the result” (the same idea the Caesar breaker uses at a tiny scale) works outright.
Table of Contents
- Why the Real Enigma Resists Brute Force
- The Simplification Used Here
- The Attack: Brute Force Plus Digram Scoring
- A Worked Example
- Python Implementation
- Interactive Visualizer
- Simplified Enigma vs. the Other Breakers on This Site
- FAQ
- References
Why the Real Enigma Resists Brute Force
A wartime Enigma operator’s full key had three independent parts, and an attacker who doesn’t already know two of them has to search all three at once:
- Rotor order. The German Army/Air Force Enigma I chose 3 rotors from a set of 5 and arranged them in the machine in a specific left-to-right order: 5 × 4 × 3 = 60 possible orders.
- Rotor starting positions. Each of the 3 chosen rotors starts at one of 26 letters: 26³ = 17,576 possibilities, the same number this post’s simplified breaker searches.
- Plugboard wiring. Up to 10 pairs of letters are cross-wired on a plugboard in front of the rotors. The number of ways to choose up to 10 disjoint pairs from 26 letters is a famously enormous figure: 150,738,274,937,250 (~1.5 × 10^14) for exactly 10 pairs, the setting the German military actually used.
Multiply all three together and the operational key space lands somewhere north of 10^20. That’s not remotely brute-forceable, not with 1940s electromechanics and not with a modern laptop either. This is precisely why Turing’s Bombe didn’t attempt brute force at all. It exploited a known-plaintext crib (a guessed fragment of plaintext, like a stock weather-report phrase, aligned against a stretch of ciphertext) to build a “menu,” a graph of letter linkages implied by the crib. It then used a clever piece of electromechanical logic (the “diagonal board”) to test rotor orders and positions, while implicitly ruling out huge swaths of plugboard wirings without ever enumerating them individually. That’s a fundamentally different technique from anything else on this site, and reproducing it faithfully is a separate, much larger undertaking than a brute-force visualizer.
The Simplification Used Here
This breaker instead tackles a reduced version of the problem, so the core “try every key, keep the one that looks most like English” idea (the same idea behind the Caesar breaker) can run to completion:
- Rotor order is fixed and known: Rotor I, II, III, left to right (the same set used by this site’s Enigma cipher visualizer).
- Reflector is fixed and known: Historical Reflector B.
- No plugboard. Zero pairs cross-wired. This is the single biggest simplification, since the real plugboard is what made the wartime key space genuinely astronomical.
- Ring settings at their default.
The only unknown left is the rotor starting position: three letters, one per rotor, so 26 × 26 × 26 = 17,576 possibilities. That’s a smaller space than a 4-letter Vigenère key (26⁴ = 456,976) and easily within reach of an exhaustive JavaScript search running in a browser tab.
The Attack: Brute Force Plus Digram Scoring
With the key space cut down to 17,576, the method is almost embarrassingly direct:
- For each of the 17,576 rotor starting positions, run the exact same Enigma stepping and substitution logic used to encrypt (Enigma is reciprocal, so the identical process decrypts) and produce a full candidate plaintext.
- Score that candidate using digram (letter-pair) log-frequencies. This is the same statistical signal the Substitution breaker and the Vigenère/Beaufort breakers’ chi-squared step both lean on: real English has a distinctive letter-pair fingerprint (
TH,HE,IN,ER…) that survives any fixed or rotating substitution. A wrong key produces gibberish whose digram statistics look nothing like it. This is also the exact same ~390-entry bigram table (and 0.005 floor constant for unseen pairs) the Autokey breaker uses. As with that breaker, the raw score values here are specific to this table; only the ranking between candidates is meaningful, not the absolute numbers. - Keep the highest-scoring candidate. Because 17,576 is small enough to enumerate completely, there’s no need for hill-climbing or simulated annealing here. Every single key gets tried, so the best-scoring one really is the global best, guaranteed, not just the best one found.
Unlike the Substitution breaker, this uses digram statistics alone with no common-word bonus and no word boundaries at all. Real Enigma traffic was transmitted as a continuous, unspaced letter stream (often chopped into arbitrary 5-letter groups for radio operators, not real words). So this breaker’s demo ciphertext follows the same convention.
A Worked Example
Using the visualizer’s default ciphertext (920 letters, encrypted with rotor order I-II-III, Reflector B, no plugboard, starting position MFK):
-
The search tries all 17,576 candidate starting positions, fully decrypting and digram-scoring each one.
-
Two positions come out tied for the best score:
MFK(the actual encryption key) andLEK, both at a score of -882.26. Both decrypt to the exact same readable passage. Every other candidate scores at least 2,283 points lower (the next-best,RCP, manages only -3165.69). So the gap between “right” and “everything else” is decisive; the ambiguity is strictly between these two. -
That tie isn’t a search bug. It’s a genuine, well-documented quirk of Enigma’s double-stepping mechanism.
LEK’s middle rotor (E) sits exactly on its own stepping notch at the moment the first letter is enciphered. That immediately triggers a double-step (both the left and middle rotors advance together on that first keystroke). That double-step carriesLEK’s rotors to precisely the same physical state thatMFKreaches on its own very next tick, one letter in.Keypress Starting from MFKStarting from LEKBefore any keypress MFK LEK After keypress 1 MFL (ordinary step: only the right rotor advances) MFL (middle rotor at notch Eforces middle + left to step together)After keypress 2 onward Identical Identical From that point forward the two runs are indistinguishable, letter for letter, for the rest of the message. Whenever a true starting position’s middle rotor sits one letter past its notch, a search like this one will always turn up that position’s “phantom twin” tied at the exact same score.
-
Decrypting with either recovered position reveals the same coherent passage, the one this article uses to describe its own attack:
“The Enigma machine looks unbreakable because it produces a different substitution alphabet for almost every single letter… if the rotor wiring, the rotor order, and the reflector are all already known, and no plugboard is used, then the only real secret left is the starting position of the three rotors, which is a mere seventeen thousand five hundred and seventy six possibilities…”
Python Implementation
The interactive visualizer above runs this exact pipeline in JavaScript, using the same rotor wirings, notches, reflector, and digram table. Here’s the same attack in Python.
Key Features
- Faithful rotor mechanics:
rotor_forward,rotor_backward, andstep_rotorsreproduce the historical Enigma I wiring and the double-stepping anomaly exactly. This is the same logic this site’s Enigma cipher visualizer uses to encrypt, since Enigma’s reciprocal design means the identical procedure decrypts. - Exhaustive, not heuristic: Because 17,576 keys is small enough to enumerate completely,
break_enigmatries every single one. No restarts, no annealing, no risk of missing the true optimum. - Pure digram scoring: Unlike the Substitution breaker, there’s no common-word bonus and no word-boundary information. Real Enigma traffic had neither, so the scorer relies entirely on letter-pair statistics.
Code
# enigma_breaker.py
#
# Breaks a *simplified* Enigma cipher: rotor order (I-II-III), reflector
# (B), and plugboard (none) are all assumed known, leaving only the three
# rotor starting positions -- 26^3 = 17,576 possibilities -- to recover.
# That's small enough to brute-force outright and score every candidate
# decryption with digram statistics, unlike the real wartime Enigma whose
# rotor-order x plugboard combinatorics push the true key space past 10^20.
import math
ALPHA = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
# Historical Enigma I rotor wirings/notches (same as the encryption-side
# visualizer) and Reflector B.
ROTORS = [
{'wiring': 'EKMFLGDQVZNTOWYHXUSPAIBRCJ', 'notch': 'Q'}, # Rotor I (left)
{'wiring': 'AJDKSIRUXBLHWTMCQGZNPYFVOE', 'notch': 'E'}, # Rotor II (middle)
{'wiring': 'BDFHJLCPRTXVZNYEIWGAKMUSQO', 'notch': 'V'}, # Rotor III (right)
]
REFLECTOR = 'YRUHQSLDPXNGOKMIEBFZCWVJAT'
BIGRAMS = {"HE": 3.5637, "TH": 3.3246, "ER": 2.706, "IN": 2.4173, "AN": 1.9097, "RE": 1.7599, "HA": 1.5132, "EN": 1.4942, "ON": 1.4774, "OU": 1.4742, "AT": 1.3883, "ND": 1.3435, "ED": 1.2794, "TO": 1.2408, "IS": 1.1669, "IT": 1.1623, "AS": 1.1473, "NG": 1.1462, "HI": 1.0715, "TE": 1.0665, "VE": 1.0235, "AR": 0.9992, "BE": 0.9838, "OF": 0.9627, "NE": 0.9617, "OR": 0.931, "SE": 0.9101, "ST": 0.8866, "ES": 0.8682, "LE": 0.8646, "NT": 0.8177, "NO": 0.8058, "TI": 0.7947, "EL": 0.7821, "LL": 0.7803, "ME": 0.7578, "LI": 0.7559, "EA": 0.75, "CO": 0.7481, "SH": 0.699, "AL": 0.6925, "OT": 0.6475, "WA": 0.6392, "HO": 0.6282, "UR": 0.6195, "ET": 0.617, "DE": 0.6119, "WI": 0.5965, "CE": 0.5951, "LY": 0.5938, "AD": 0.5795, "WH": 0.5568, "CH": 0.5531, "IO": 0.5481, "OM": 0.5276, "FO": 0.5276, "SI": 0.5106, "RI": 0.501, "YO": 0.4978, "DI": 0.4681, "RS": 0.4594, "IL": 0.4559, "RO": 0.4546, "EE": 0.4546, "MA": 0.4532, "SS": 0.4481, "US": 0.4474, "UT": 0.4426, "SO": 0.4332, "OW": 0.4327, "WE": 0.4213, "UL": 0.4201, "PE": 0.4183, "IM": 0.4061, "EC": 0.3951, "LD": 0.3948, "NC": 0.3925, "AI": 0.3723, "RA": 0.3703, "GH": 0.3696, "TA": 0.3682, "AB": 0.368, "IC": 0.3618, "MI": 0.3602, "SU": 0.3576, "IE": 0.3565, "NS": 0.3535, "IR": 0.353, "LA": 0.3443, "UN": 0.3397, "EV": 0.3381, "CA": 0.3374, "EM": 0.3264, "GE": 0.3252, "PR": 0.325, "AV": 0.323, "SA": 0.3188, "LO": 0.3184, "RY": 0.3151, "CT": 0.3151, "ID": 0.3131, "AM": 0.3122, "MO": 0.3112, "OO": 0.3108, "RT": 0.3078, "AY": 0.286, "TT": 0.2807, "AC": 0.2729, "EY": 0.2724, "OS": 0.2708, "FE": 0.2704, "MR": 0.2669, "BU": 0.2651, "EI": 0.2522, "KE": 0.2517, "DA": 0.2501, "PO": 0.2451, "OL": 0.2446, "UC": 0.2442, "BL": 0.2439, "WO": 0.2416, "PA": 0.2364, "TR": 0.2297, "HT": 0.2281, "IG": 0.2194, "DO": 0.218, "IV": 0.2145, "PL": 0.2104, "NI": 0.2083, "VI": 0.206, "UG": 0.2051, "RD": 0.2035, "AG": 0.2017, "FI": 0.2012, "FR": 0.1994, "MY": 0.1941, "AP": 0.1927, "FA": 0.1877, "NN": 0.1867, "TY": 0.1844, "IZ": 0.1838, "TU": 0.1812, "IF": 0.1766, "EX": 0.1759, "GA": 0.1716, "MP": 0.1714, "IA": 0.1702, "MU": 0.1693, "BY": 0.1681, "TL": 0.1681, "SP": 0.1663, "PP": 0.1642, "RC": 0.1619, "EF": 0.1596, "ZA": 0.1539, "BO": 0.1537, "GR": 0.1525, "LF": 0.1498, "QU": 0.1488, "OP": 0.1477, "EP": 0.1463, "RN": 0.1413, "NY": 0.1401, "FF": 0.1397, "AK": 0.139, "NA": 0.1383, "RR": 0.1374, "GI": 0.1309, "AU": 0.1298, "GO": 0.1293, "OV": 0.1279, "OD": 0.1279, "CI": 0.1263, "TS": 0.1245, "CY": 0.1217, "KI": 0.1171, "GL": 0.1169, "RL": 0.1137, "LU": 0.1116, "KN": 0.1112, "WN": 0.1107, "AF": 0.1103, "UA": 0.1075, "UP": 0.1075, "CK": 0.1045, "BI": 0.1018, "FT": 0.1015, "PI": 0.1008, "DY": 0.0983, "RM": 0.0976, "DS": 0.0942, "UE": 0.0935, "OK": 0.0919, "NL": 0.0914, "CU": 0.088, "EW": 0.0871, "FU": 0.0861, "NK": 0.0861, "UI": 0.0859, "VA": 0.0852, "PT": 0.0838, "YE": 0.0834, "OB": 0.0827, "YS": 0.0825, "SC": 0.0806, "OI": 0.0806, "AW": 0.0795, "CR": 0.0792, "CL": 0.0783, "EG": 0.0772, "CC": 0.0769, "BR": 0.0767, "IB": 0.0724, "BA": 0.0719, "JA": 0.0714, "MS": 0.0712, "RU": 0.071, "XP": 0.0689, "HU": 0.0682, "LT": 0.0634, "UD": 0.0629, "NV": 0.0595, "TW": 0.0588, "RG": 0.0586, "MM": 0.0586, "IK": 0.0583, "UM": 0.0581, "UB": 0.057, "DR": 0.0565, "JE": 0.056, "VO": 0.0558, "LK": 0.0556, "GS": 0.0549, "LS": 0.0549, "DU": 0.0544, "NF": 0.0528, "OC": 0.0519, "RF": 0.0517, "DD": 0.0501, "RV": 0.0501, "MB": 0.0473, "JU": 0.0471, "KH": 0.0462, "YT": 0.0455, "EQ": 0.0443, "BJ": 0.0436, "PS": 0.0427, "PU": 0.0416, "IP": 0.0416, "YD": 0.0404, "NU": 0.0395, "HR": 0.0386, "YI": 0.0384, "XC": 0.0377, "BS": 0.0372, "GU": 0.037, "DL": 0.0368, "JO": 0.0361, "EH": 0.0351, "XT": 0.0351, "SL": 0.0345, "SM": 0.034, "FL": 0.0319, "SW": 0.031, "SF": 0.031, "PY": 0.031, "EO": 0.0305, "RP": 0.0301, "DG": 0.0296, "WR": 0.0296, "LW": 0.0292, "RH": 0.0289, "GN": 0.0289, "RK": 0.028, "LV": 0.0271, "BT": 0.0266, "OG": 0.0264, "PH": 0.0255, "OA": 0.0253, "KS": 0.0248, "TC": 0.0239, "SK": 0.0237, "DV": 0.0237, "ZZ": 0.0237, "OE": 0.0234, "XI": 0.023, "CQ": 0.023, "OH": 0.0227, "ZY": 0.0225, "DM": 0.0218, "TF": 0.0214, "EK": 0.0202, "WS": 0.0202, "GB": 0.0202, "HM": 0.0195, "RW": 0.0193, "LM": 0.0184, "HY": 0.0181, "TN": 0.0175, "OY": 0.0172, "XA": 0.017, "NQ": 0.017, "NJ": 0.017, "TM": 0.0163, "YB": 0.0161, "SY": 0.0156, "XE": 0.0154, "IX": 0.0154, "WL": 0.0152, "DN": 0.0149, "SB": 0.0136, "MF": 0.0136, "RB": 0.0131, "UF": 0.0126, "HB": 0.0122, "EJ": 0.0117, "HL": 0.0108, "NX": 0.0108, "LP": 0.0106, "XX": 0.0106, "ZE": 0.0106, "II": 0.0101, "YM": 0.0099, "EB": 0.0096, "FY": 0.0096, "GT": 0.0094, "KF": 0.0092, "SG": 0.009, "YR": 0.009, "TZ": 0.0085, "ZW": 0.0085, "GG": 0.0083, "NW": 0.008, "SD": 0.0078, "NH": 0.0071, "LC": 0.0071, "LR": 0.0067, "HS": 0.0064, "LN": 0.0055, "SN": 0.0055, "GY": 0.0055, "GM": 0.0053, "AZ": 0.0053, "YN": 0.0051, "LG": 0.0048, "IU": 0.0048, "MN": 0.0048, "NP": 0.0048, "YL": 0.0046, "DF": 0.0046, "DW": 0.0044, "KA": 0.0041, "KY": 0.0041, "XV": 0.0041, "EU": 0.0041, "NR": 0.0039, "KW": 0.0037, "AO": 0.0037, "ML": 0.0034, "KL": 0.0034, "WK": 0.0032, "XL": 0.0032, "CS": 0.003, "ZI": 0.0028, "NM": 0.0028, "XH": 0.0025, "HF": 0.0023, "HN": 0.0021, "BB": 0.0021, "UO": 0.0021, "HD": 0.0021, "YF": 0.0018, "MT": 0.0018, "VY": 0.0018, "OX": 0.0016, "SR": 0.0016, "BH": 0.0016, "VU": 0.0014, "HW": 0.0014, "IQ": 0.0014, "YP": 0.0014, "OQ": 0.0014, "AE": 0.0014, "AH": 0.0014, "WD": 0.0014, "SQ": 0.0011, "WF": 0.0011, "AX": 0.0011, "UY": 0.0011, "YA": 0.0011, "IH": 0.0011}
FLOOR = 0.005
def rotor_forward(rotor_index, pos, letter_index):
wiring = ROTORS[rotor_index]['wiring']
shifted = (letter_index + pos) % 26
out_letter = wiring[shifted]
return (ALPHA.index(out_letter) - pos + 26) % 26
def rotor_backward(rotor_index, pos, letter_index):
wiring = ROTORS[rotor_index]['wiring']
shifted = (letter_index + pos) % 26
target_letter = ALPHA[shifted]
j = wiring.index(target_letter)
return (j - pos + 26) % 26
# Reproduces Enigma's "double-stepping anomaly": if the middle rotor sits
# on its own notch, BOTH the middle and left rotors advance on this step,
# not just the middle one -- a real quirk of the historical mechanism.
def step_rotors(positions):
notch_index = lambda r: ALPHA.index(ROTORS[r]['notch'])
right_at_notch = positions[2] == notch_index(2)
middle_at_notch = positions[1] == notch_index(1)
if middle_at_notch:
positions[1] = (positions[1] + 1) % 26
positions[0] = (positions[0] + 1) % 26
elif right_at_notch:
positions[1] = (positions[1] + 1) % 26
positions[2] = (positions[2] + 1) % 26
def encrypt_letter(positions, letter_index):
idx = rotor_forward(2, positions[2], letter_index)
idx = rotor_forward(1, positions[1], idx)
idx = rotor_forward(0, positions[0], idx)
idx = ALPHA.index(REFLECTOR[idx])
idx = rotor_backward(0, positions[0], idx)
idx = rotor_backward(1, positions[1], idx)
idx = rotor_backward(2, positions[2], idx)
return idx
# Enigma is reciprocal: this same procedure both encrypts and decrypts.
def run_machine(text, start):
positions = [ALPHA.index(c) for c in start]
out = []
for ch in text:
step_rotors(positions)
out.append(ALPHA[encrypt_letter(positions, ALPHA.index(ch))])
return ''.join(out)
def score_text(text):
return sum(math.log(BIGRAMS.get(text[i - 1:i + 1], FLOOR)) for i in range(1, len(text)))
def break_enigma(ciphertext):
best_start, best_score, best_plain = None, float('-inf'), ''
for a in range(26):
for b in range(26):
for c in range(26):
start = ALPHA[a] + ALPHA[b] + ALPHA[c]
plain = run_machine(ciphertext, start)
score = score_text(plain)
if score > best_score:
best_start, best_score, best_plain = start, score, plain
return {'start': best_start, 'score': best_score, 'plaintext': best_plain}
if __name__ == '__main__':
ciphertext = "ILQKDOEYYIKLTSUDOHHGGQVOHSWSMMEOSLUJLJFKPJFSRZMSLHJZPPRBMDVRZDOMBVSNEVAMDLKZXRCDWTDITCPWJRKABXCJFHGQDFPJYIMIBOZNPOBZSXFZOZSIRKJXXZOXIPFFUZBASSZNELPKMPKDODEUCMGPXUVFFNSWHFVXFKAMBZWOROCQSVPXUOBWSTDMWBPMLISHUCYLCRSVMZZQRMFQFOUCKRMZRHIPYOAXXCLCWPDIVLLFCLRIKPSRINMWVJDIBAGHJXZGRBFVGVKRNYTSYZLKTLPFHRCQAMLYCXNXTRMFRYVPKBPGYIENSRYZWTVFQDWGKIWMPKPFXLFPSMROFUZXEYRXBWDLOQHERQITSPOGCEWBVCGRFMLKNVAWAWICJPECNLDGYCXMCNRVRBDLXZHTMXHNUQBTBDWNKAHYOBFFPJXDGWUNWWDPYZQTYLXNNKRVLRYYOFSYLRATAKMPULGJCAEADBYDXWGBPXJAROBORAFMRLKJHYZRKZTCSZCGJFUHCHAILBLYETDUXFDXBGIOAVAOCKAJLOTFKAKGSRBIWOJPUHBFCMVVCQUDHUBNPZBVPRCIEKLDWFNEJOEPVGBSFPIKHVIABOPOXFSMMODYCUAPVPLAJHIRWCDPAFHAGHWFOXIGQGUULMQWNXVXDVNPWKEGUAFWHXLIFTYNLOYERRVZSNYTQLIDDDMHKUBGUCMVPAGGOIJIBBBKBALORYMOGVOTRRHHVPDVIYBJNELKRJJOUTCGOTIBUMBIAXDVTIEOMDCJEQRQNIAJJOUFLIHVECTUNADXUEILJPGFQUFJLJMKRRJIPOZURHZAHVMFBMUEUYGASUQFFVKZMUSBTMNATWUYQUEWFQTJTWFBQFZGCFCJTMEOHJGFFLHYAOVZETHFVRGLIJMDDZUV"
result = break_enigma(ciphertext)
print('Recovered rotor start:', result['start'])
print(f"Best fit score: {result['score']:.2f}")
print()
print('Recovered plaintext:')
print(result['plaintext'])
The script above is complete and runnable as-is, using the same rotor wirings, reflector, digram table, and demo ciphertext as the JavaScript visualizer. Because best_score only updates on a strict improvement, break_enigma keeps whichever of the two tied positions (MFK or LEK, both scoring -882.26) it happens to encounter first in the A→Z nested loop. Either one decrypts to the exact same correct passage, for the double-stepping reasons explained above.
For Fun: The Same Thing in ~15 Lines
Same spirit as the Caesar Cipher Breaker’s 5-line one-liner: not something to learn the algorithm from, just a demonstration of how far Python’s expression syntax stretches. This packs the rotor mechanics and the full 17,576-key search into about 15 lines, using the exact same ~390-entry BIGRAMS table as the readable version above.
import math
A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
R = [('EKMFLGDQVZNTOWYHXUSPAIBRCJ', 'Q'), ('AJDKSIRUXBLHWTMCQGZNPYFVOE', 'E'), ('BDFHJLCPRTXVZNYEIWGAKMUSQO', 'V')]
REF = 'YRUHQSLDPXNGOKMIEBFZCWVJAT'
FLOOR = 0.005
BIGRAMS = {"HE": 3.5637, "TH": 3.3246, "ER": 2.706, "IN": 2.4173, "AN": 1.9097, "RE": 1.7599, "HA": 1.5132, "EN": 1.4942, "ON": 1.4774, "OU": 1.4742, "AT": 1.3883, "ND": 1.3435, "ED": 1.2794, "TO": 1.2408, "IS": 1.1669, "IT": 1.1623, "AS": 1.1473, "NG": 1.1462, "HI": 1.0715, "TE": 1.0665, "VE": 1.0235, "AR": 0.9992, "BE": 0.9838, "OF": 0.9627, "NE": 0.9617, "OR": 0.931, "SE": 0.9101, "ST": 0.8866, "ES": 0.8682, "LE": 0.8646, "NT": 0.8177, "NO": 0.8058, "TI": 0.7947, "EL": 0.7821, "LL": 0.7803, "ME": 0.7578, "LI": 0.7559, "EA": 0.75, "CO": 0.7481, "SH": 0.699, "AL": 0.6925, "OT": 0.6475, "WA": 0.6392, "HO": 0.6282, "UR": 0.6195, "ET": 0.617, "DE": 0.6119, "WI": 0.5965, "CE": 0.5951, "LY": 0.5938, "AD": 0.5795, "WH": 0.5568, "CH": 0.5531, "IO": 0.5481, "OM": 0.5276, "FO": 0.5276, "SI": 0.5106, "RI": 0.501, "YO": 0.4978, "DI": 0.4681, "RS": 0.4594, "IL": 0.4559, "RO": 0.4546, "EE": 0.4546, "MA": 0.4532, "SS": 0.4481, "US": 0.4474, "UT": 0.4426, "SO": 0.4332, "OW": 0.4327, "WE": 0.4213, "UL": 0.4201, "PE": 0.4183, "IM": 0.4061, "EC": 0.3951, "LD": 0.3948, "NC": 0.3925, "AI": 0.3723, "RA": 0.3703, "GH": 0.3696, "TA": 0.3682, "AB": 0.368, "IC": 0.3618, "MI": 0.3602, "SU": 0.3576, "IE": 0.3565, "NS": 0.3535, "IR": 0.353, "LA": 0.3443, "UN": 0.3397, "EV": 0.3381, "CA": 0.3374, "EM": 0.3264, "GE": 0.3252, "PR": 0.325, "AV": 0.323, "SA": 0.3188, "LO": 0.3184, "RY": 0.3151, "CT": 0.3151, "ID": 0.3131, "AM": 0.3122, "MO": 0.3112, "OO": 0.3108, "RT": 0.3078, "AY": 0.286, "TT": 0.2807, "AC": 0.2729, "EY": 0.2724, "OS": 0.2708, "FE": 0.2704, "MR": 0.2669, "BU": 0.2651, "EI": 0.2522, "KE": 0.2517, "DA": 0.2501, "PO": 0.2451, "OL": 0.2446, "UC": 0.2442, "BL": 0.2439, "WO": 0.2416, "PA": 0.2364, "TR": 0.2297, "HT": 0.2281, "IG": 0.2194, "DO": 0.218, "IV": 0.2145, "PL": 0.2104, "NI": 0.2083, "VI": 0.206, "UG": 0.2051, "RD": 0.2035, "AG": 0.2017, "FI": 0.2012, "FR": 0.1994, "MY": 0.1941, "AP": 0.1927, "FA": 0.1877, "NN": 0.1867, "TY": 0.1844, "IZ": 0.1838, "TU": 0.1812, "IF": 0.1766, "EX": 0.1759, "GA": 0.1716, "MP": 0.1714, "IA": 0.1702, "MU": 0.1693, "BY": 0.1681, "TL": 0.1681, "SP": 0.1663, "PP": 0.1642, "RC": 0.1619, "EF": 0.1596, "ZA": 0.1539, "BO": 0.1537, "GR": 0.1525, "LF": 0.1498, "QU": 0.1488, "OP": 0.1477, "EP": 0.1463, "RN": 0.1413, "NY": 0.1401, "FF": 0.1397, "AK": 0.139, "NA": 0.1383, "RR": 0.1374, "GI": 0.1309, "AU": 0.1298, "GO": 0.1293, "OV": 0.1279, "OD": 0.1279, "CI": 0.1263, "TS": 0.1245, "CY": 0.1217, "KI": 0.1171, "GL": 0.1169, "RL": 0.1137, "LU": 0.1116, "KN": 0.1112, "WN": 0.1107, "AF": 0.1103, "UA": 0.1075, "UP": 0.1075, "CK": 0.1045, "BI": 0.1018, "FT": 0.1015, "PI": 0.1008, "DY": 0.0983, "RM": 0.0976, "DS": 0.0942, "UE": 0.0935, "OK": 0.0919, "NL": 0.0914, "CU": 0.088, "EW": 0.0871, "FU": 0.0861, "NK": 0.0861, "UI": 0.0859, "VA": 0.0852, "PT": 0.0838, "YE": 0.0834, "OB": 0.0827, "YS": 0.0825, "SC": 0.0806, "OI": 0.0806, "AW": 0.0795, "CR": 0.0792, "CL": 0.0783, "EG": 0.0772, "CC": 0.0769, "BR": 0.0767, "IB": 0.0724, "BA": 0.0719, "JA": 0.0714, "MS": 0.0712, "RU": 0.071, "XP": 0.0689, "HU": 0.0682, "LT": 0.0634, "UD": 0.0629, "NV": 0.0595, "TW": 0.0588, "RG": 0.0586, "MM": 0.0586, "IK": 0.0583, "UM": 0.0581, "UB": 0.057, "DR": 0.0565, "JE": 0.056, "VO": 0.0558, "LK": 0.0556, "GS": 0.0549, "LS": 0.0549, "DU": 0.0544, "NF": 0.0528, "OC": 0.0519, "RF": 0.0517, "DD": 0.0501, "RV": 0.0501, "MB": 0.0473, "JU": 0.0471, "KH": 0.0462, "YT": 0.0455, "EQ": 0.0443, "BJ": 0.0436, "PS": 0.0427, "PU": 0.0416, "IP": 0.0416, "YD": 0.0404, "NU": 0.0395, "HR": 0.0386, "YI": 0.0384, "XC": 0.0377, "BS": 0.0372, "GU": 0.037, "DL": 0.0368, "JO": 0.0361, "EH": 0.0351, "XT": 0.0351, "SL": 0.0345, "SM": 0.034, "FL": 0.0319, "SW": 0.031, "SF": 0.031, "PY": 0.031, "EO": 0.0305, "RP": 0.0301, "DG": 0.0296, "WR": 0.0296, "LW": 0.0292, "RH": 0.0289, "GN": 0.0289, "RK": 0.028, "LV": 0.0271, "BT": 0.0266, "OG": 0.0264, "PH": 0.0255, "OA": 0.0253, "KS": 0.0248, "TC": 0.0239, "SK": 0.0237, "DV": 0.0237, "ZZ": 0.0237, "OE": 0.0234, "XI": 0.023, "CQ": 0.023, "OH": 0.0227, "ZY": 0.0225, "DM": 0.0218, "TF": 0.0214, "EK": 0.0202, "WS": 0.0202, "GB": 0.0202, "HM": 0.0195, "RW": 0.0193, "LM": 0.0184, "HY": 0.0181, "TN": 0.0175, "OY": 0.0172, "XA": 0.017, "NQ": 0.017, "NJ": 0.017, "TM": 0.0163, "YB": 0.0161, "SY": 0.0156, "XE": 0.0154, "IX": 0.0154, "WL": 0.0152, "DN": 0.0149, "SB": 0.0136, "MF": 0.0136, "RB": 0.0131, "UF": 0.0126, "HB": 0.0122, "EJ": 0.0117, "HL": 0.0108, "NX": 0.0108, "LP": 0.0106, "XX": 0.0106, "ZE": 0.0106, "II": 0.0101, "YM": 0.0099, "EB": 0.0096, "FY": 0.0096, "GT": 0.0094, "KF": 0.0092, "SG": 0.009, "YR": 0.009, "TZ": 0.0085, "ZW": 0.0085, "GG": 0.0083, "NW": 0.008, "SD": 0.0078, "NH": 0.0071, "LC": 0.0071, "LR": 0.0067, "HS": 0.0064, "LN": 0.0055, "SN": 0.0055, "GY": 0.0055, "GM": 0.0053, "AZ": 0.0053, "YN": 0.0051, "LG": 0.0048, "IU": 0.0048, "MN": 0.0048, "NP": 0.0048, "YL": 0.0046, "DF": 0.0046, "DW": 0.0044, "KA": 0.0041, "KY": 0.0041, "XV": 0.0041, "EU": 0.0041, "NR": 0.0039, "KW": 0.0037, "AO": 0.0037, "ML": 0.0034, "KL": 0.0034, "WK": 0.0032, "XL": 0.0032, "CS": 0.003, "ZI": 0.0028, "NM": 0.0028, "XH": 0.0025, "HF": 0.0023, "HN": 0.0021, "BB": 0.0021, "UO": 0.0021, "HD": 0.0021, "YF": 0.0018, "MT": 0.0018, "VY": 0.0018, "OX": 0.0016, "SR": 0.0016, "BH": 0.0016, "VU": 0.0014, "HW": 0.0014, "IQ": 0.0014, "YP": 0.0014, "OQ": 0.0014, "AE": 0.0014, "AH": 0.0014, "WD": 0.0014, "SQ": 0.0011, "WF": 0.0011, "AX": 0.0011, "UY": 0.0011, "YA": 0.0011, "IH": 0.0011}
fwd = lambda r, p, i: (A.index(R[r][0][(i + p) % 26]) - p) % 26
bwd = lambda r, p, i: (R[r][0].index(A[(i + p) % 26]) - p) % 26
score = lambda t: sum(math.log(BIGRAMS.get(t[i-1:i+1], FLOOR)) for i in range(1, len(t)))
def step(pos):
if pos[1] == A.index(R[1][1]): pos[0] = (pos[0] + 1) % 26; pos[1] = (pos[1] + 1) % 26
elif pos[2] == A.index(R[2][1]): pos[1] = (pos[1] + 1) % 26
pos[2] = (pos[2] + 1) % 26
def run(text, start):
pos = [A.index(c) for c in start]
out = []
for ch in text:
step(pos)
i = fwd(0, pos[0], fwd(1, pos[1], fwd(2, pos[2], A.index(ch))))
i = bwd(2, pos[2], bwd(1, pos[1], bwd(0, pos[0], A.index(REF[i]))))
out.append(A[i])
return ''.join(out)
def break_enigma(ct):
return max(((a + b + c, run(ct, a + b + c)) for a in A for b in A for c in A), key=lambda x: score(x[1]))
if __name__ == '__main__':
ciphertext = "ILQKDOEYYIKLTSUDOHHGGQVOHSWSMMEOSLUJLJFKPJFSRZMSLHJZPPRBMDVRZDOMBVSNEVAMDLKZXRCDWTDITCPWJRKABXCJFHGQDFPJYIMIBOZNPOBZSXFZOZSIRKJXXZOXIPFFUZBASSZNELPKMPKDODEUCMGPXUVFFNSWHFVXFKAMBZWOROCQSVPXUOBWSTDMWBPMLISHUCYLCRSVMZZQRMFQFOUCKRMZRHIPYOAXXCLCWPDIVLLFCLRIKPSRINMWVJDIBAGHJXZGRBFVGVKRNYTSYZLKTLPFHRCQAMLYCXNXTRMFRYVPKBPGYIENSRYZWTVFQDWGKIWMPKPFXLFPSMROFUZXEYRXBWDLOQHERQITSPOGCEWBVCGRFMLKNVAWAWICJPECNLDGYCXMCNRVRBDLXZHTMXHNUQBTBDWNKAHYOBFFPJXDGWUNWWDPYZQTYLXNNKRVLRYYOFSYLRATAKMPULGJCAEADBYDXWGBPXJAROBORAFMRLKJHYZRKZTCSZCGJFUHCHAILBLYETDUXFDXBGIOAVAOCKAJLOTFKAKGSRBIWOJPUHBFCMVVCQUDHUBNPZBVPRCIEKLDWFNEJOEPVGBSFPIKHVIABOPOXFSMMODYCUAPVPLAJHIRWCDPAFHAGHWFOXIGQGUULMQWNXVXDVNPWKEGUAFWHXLIFTYNLOYERRVZSNYTQLIDDDMHKUBGUCMVPAGGOIJIBBBKBALORYMOGVOTRRHHVPDVIYBJNELKRJJOUTCGOTIBUMBIAXDVTIEOMDCJEQRQNIAJJOUFLIHVECTUNADXUEILJPGFQUFJLJMKRRJIPOZURHZAHVMFBMUEUYGASUQFFVKZMUSBTMNATWUYQUEWFQTJTWFBQFZGCFCJTMEOHJGFFLHYAOVZETHFVRGLIJMDDZUV"
start, plain = break_enigma(ciphertext)
print('Recovered rotor start:', start)
print(f"Best fit score: {score(plain):.2f}")
print()
print('Recovered plaintext:')
print(plain)
fwd, bwd, and score collapse into one-liners cleanly since they’re pure functions of their arguments. step and run stay as short defs rather than lambdas, for the same reason the Substitution breaker’s golfed anneal did: they mutate rotor state and branch on it, which needs statements, not expressions. The nested calls inside run, fwd(0, ..., fwd(1, ..., fwd(2, ..., idx))) for the forward pass and the mirrored bwd chain for the return pass, are the one-line equivalent of the readable version’s three sequential reassignments to idx. They’re just composed inside-out instead of listed top-to-bottom. break_enigma’s max(...) over a generator of all 17,576 (a, b, c) triples replaces the readable version’s explicit best-tracking loop and returns the winning (start, plaintext) pair directly, rather than a running best-so-far. Ties resolve to whichever candidate the generator produces first. Since it walks the same A→Z nested order, that’s LEK before MFK, exactly matching the readable version’s own tie-breaking behavior.
Going Even Further: Zero defs
The version above still keeps step and run as real functions because they mutate state and loop: statements, not expressions, need a def. It turns out that’s not actually a hard requirement, just a convenience: Python lets a lambda mutate an object too, as long as the mutation is expressed as a call (like list.__setitem__) rather than an assignment statement. Pushed to that extreme, the entire attack (rotor stepping, encryption, scoring, and the full 17,576-key search) compresses into six physical lines total, digram table and demo runner included:
import math; A='ABCDEFGHIJKLMNOPQRSTUVWXYZ'; R=[('EKMFLGDQVZNTOWYHXUSPAIBRCJ','Q'),('AJDKSIRUXBLHWTMCQGZNPYFVOE','E'),('BDFHJLCPRTXVZNYEIWGAKMUSQO','V')]; REF='YRUHQSLDPXNGOKMIEBFZCWVJAT'; FLOOR=0.005; N1=A.index(R[1][1]); N2=A.index(R[2][1])
BIGRAMS={"HE":3.5637,"TH":3.3246,"ER":2.706,"IN":2.4173,"AN":1.9097,"RE":1.7599,"HA":1.5132,"EN":1.4942,"ON":1.4774,"OU":1.4742,"AT":1.3883,"ND":1.3435,"ED":1.2794,"TO":1.2408,"IS":1.1669,"IT":1.1623,"AS":1.1473,"NG":1.1462,"HI":1.0715,"TE":1.0665,"VE":1.0235,"AR":0.9992,"BE":0.9838,"OF":0.9627,"NE":0.9617,"OR":0.931,"SE":0.9101,"ST":0.8866,"ES":0.8682,"LE":0.8646,"NT":0.8177,"NO":0.8058,"TI":0.7947,"EL":0.7821,"LL":0.7803,"ME":0.7578,"LI":0.7559,"EA":0.75,"CO":0.7481,"SH":0.699,"AL":0.6925,"OT":0.6475,"WA":0.6392,"HO":0.6282,"UR":0.6195,"ET":0.617,"DE":0.6119,"WI":0.5965,"CE":0.5951,"LY":0.5938,"AD":0.5795,"WH":0.5568,"CH":0.5531,"IO":0.5481,"OM":0.5276,"FO":0.5276,"SI":0.5106,"RI":0.501,"YO":0.4978,"DI":0.4681,"RS":0.4594,"IL":0.4559,"RO":0.4546,"EE":0.4546,"MA":0.4532,"SS":0.4481,"US":0.4474,"UT":0.4426,"SO":0.4332,"OW":0.4327,"WE":0.4213,"UL":0.4201,"PE":0.4183,"IM":0.4061,"EC":0.3951,"LD":0.3948,"NC":0.3925,"AI":0.3723,"RA":0.3703,"GH":0.3696,"TA":0.3682,"AB":0.368,"IC":0.3618,"MI":0.3602,"SU":0.3576,"IE":0.3565,"NS":0.3535,"IR":0.353,"LA":0.3443,"UN":0.3397,"EV":0.3381,"CA":0.3374,"EM":0.3264,"GE":0.3252,"PR":0.325,"AV":0.323,"SA":0.3188,"LO":0.3184,"RY":0.3151,"CT":0.3151,"ID":0.3131,"AM":0.3122,"MO":0.3112,"OO":0.3108,"RT":0.3078,"AY":0.286,"TT":0.2807,"AC":0.2729,"EY":0.2724,"OS":0.2708,"FE":0.2704,"MR":0.2669,"BU":0.2651,"EI":0.2522,"KE":0.2517,"DA":0.2501,"PO":0.2451,"OL":0.2446,"UC":0.2442,"BL":0.2439,"WO":0.2416,"PA":0.2364,"TR":0.2297,"HT":0.2281,"IG":0.2194,"DO":0.218,"IV":0.2145,"PL":0.2104,"NI":0.2083,"VI":0.206,"UG":0.2051,"RD":0.2035,"AG":0.2017,"FI":0.2012,"FR":0.1994,"MY":0.1941,"AP":0.1927,"FA":0.1877,"NN":0.1867,"TY":0.1844,"IZ":0.1838,"TU":0.1812,"IF":0.1766,"EX":0.1759,"GA":0.1716,"MP":0.1714,"IA":0.1702,"MU":0.1693,"BY":0.1681,"TL":0.1681,"SP":0.1663,"PP":0.1642,"RC":0.1619,"EF":0.1596,"ZA":0.1539,"BO":0.1537,"GR":0.1525,"LF":0.1498,"QU":0.1488,"OP":0.1477,"EP":0.1463,"RN":0.1413,"NY":0.1401,"FF":0.1397,"AK":0.139,"NA":0.1383,"RR":0.1374,"GI":0.1309,"AU":0.1298,"GO":0.1293,"OV":0.1279,"OD":0.1279,"CI":0.1263,"TS":0.1245,"CY":0.1217,"KI":0.1171,"GL":0.1169,"RL":0.1137,"LU":0.1116,"KN":0.1112,"WN":0.1107,"AF":0.1103,"UA":0.1075,"UP":0.1075,"CK":0.1045,"BI":0.1018,"FT":0.1015,"PI":0.1008,"DY":0.0983,"RM":0.0976,"DS":0.0942,"UE":0.0935,"OK":0.0919,"NL":0.0914,"CU":0.088,"EW":0.0871,"FU":0.0861,"NK":0.0861,"UI":0.0859,"VA":0.0852,"PT":0.0838,"YE":0.0834,"OB":0.0827,"YS":0.0825,"SC":0.0806,"OI":0.0806,"AW":0.0795,"CR":0.0792,"CL":0.0783,"EG":0.0772,"CC":0.0769,"BR":0.0767,"IB":0.0724,"BA":0.0719,"JA":0.0714,"MS":0.0712,"RU":0.071,"XP":0.0689,"HU":0.0682,"LT":0.0634,"UD":0.0629,"NV":0.0595,"TW":0.0588,"RG":0.0586,"MM":0.0586,"IK":0.0583,"UM":0.0581,"UB":0.057,"DR":0.0565,"JE":0.056,"VO":0.0558,"LK":0.0556,"GS":0.0549,"LS":0.0549,"DU":0.0544,"NF":0.0528,"OC":0.0519,"RF":0.0517,"DD":0.0501,"RV":0.0501,"MB":0.0473,"JU":0.0471,"KH":0.0462,"YT":0.0455,"EQ":0.0443,"BJ":0.0436,"PS":0.0427,"PU":0.0416,"IP":0.0416,"YD":0.0404,"NU":0.0395,"HR":0.0386,"YI":0.0384,"XC":0.0377,"BS":0.0372,"GU":0.037,"DL":0.0368,"JO":0.0361,"EH":0.0351,"XT":0.0351,"SL":0.0345,"SM":0.034,"FL":0.0319,"SW":0.031,"SF":0.031,"PY":0.031,"EO":0.0305,"RP":0.0301,"DG":0.0296,"WR":0.0296,"LW":0.0292,"RH":0.0289,"GN":0.0289,"RK":0.028,"LV":0.0271,"BT":0.0266,"OG":0.0264,"PH":0.0255,"OA":0.0253,"KS":0.0248,"TC":0.0239,"SK":0.0237,"DV":0.0237,"ZZ":0.0237,"OE":0.0234,"XI":0.023,"CQ":0.023,"OH":0.0227,"ZY":0.0225,"DM":0.0218,"TF":0.0214,"EK":0.0202,"WS":0.0202,"GB":0.0202,"HM":0.0195,"RW":0.0193,"LM":0.0184,"HY":0.0181,"TN":0.0175,"OY":0.0172,"XA":0.017,"NQ":0.017,"NJ":0.017,"TM":0.0163,"YB":0.0161,"SY":0.0156,"XE":0.0154,"IX":0.0154,"WL":0.0152,"DN":0.0149,"SB":0.0136,"MF":0.0136,"RB":0.0131,"UF":0.0126,"HB":0.0122,"EJ":0.0117,"HL":0.0108,"NX":0.0108,"LP":0.0106,"XX":0.0106,"ZE":0.0106,"II":0.0101,"YM":0.0099,"EB":0.0096,"FY":0.0096,"GT":0.0094,"KF":0.0092,"SG":0.009,"YR":0.009,"TZ":0.0085,"ZW":0.0085,"GG":0.0083,"NW":0.008,"SD":0.0078,"NH":0.0071,"LC":0.0071,"LR":0.0067,"HS":0.0064,"LN":0.0055,"SN":0.0055,"GY":0.0055,"GM":0.0053,"AZ":0.0053,"YN":0.0051,"LG":0.0048,"IU":0.0048,"MN":0.0048,"NP":0.0048,"YL":0.0046,"DF":0.0046,"DW":0.0044,"KA":0.0041,"KY":0.0041,"XV":0.0041,"EU":0.0041,"NR":0.0039,"KW":0.0037,"AO":0.0037,"ML":0.0034,"KL":0.0034,"WK":0.0032,"XL":0.0032,"CS":0.003,"ZI":0.0028,"NM":0.0028,"XH":0.0025,"HF":0.0023,"HN":0.0021,"BB":0.0021,"UO":0.0021,"HD":0.0021,"YF":0.0018,"MT":0.0018,"VY":0.0018,"OX":0.0016,"SR":0.0016,"BH":0.0016,"VU":0.0014,"HW":0.0014,"IQ":0.0014,"YP":0.0014,"OQ":0.0014,"AE":0.0014,"AH":0.0014,"WD":0.0014,"SQ":0.0011,"WF":0.0011,"AX":0.0011,"UY":0.0011,"YA":0.0011,"IH":0.0011}
fwd=lambda r,p,i:(A.index(R[r][0][(i+p)%26])-p)%26; bwd=lambda r,p,i:(R[r][0].index(A[(i+p)%26])-p)%26; fw=lambda pos,x:fwd(0,pos[0],fwd(1,pos[1],fwd(2,pos[2],x))); bw=lambda pos,x:bwd(2,pos[2],bwd(1,pos[1],bwd(0,pos[0],x))); score=lambda t:sum(math.log(BIGRAMS.get(t[i-1:i+1],FLOOR)) for i in range(1,len(t))); step=lambda pos:(pos.__setitem__(0,(pos[0]+(pos[1]==N1))%26),pos.__setitem__(1,(pos[1]+((pos[1]==N1) or (pos[2]==N2)))%26),pos.__setitem__(2,(pos[2]+1)%26))[2]
run=lambda text,start:(lambda pos:''.join((step(pos),A[bw(pos,A.index(REF[fw(pos,A.index(ch))]))])[1] for ch in text))([A.index(c) for c in start])
break_enigma=lambda ct:max(((a+b+c,run(ct,a+b+c)) for a in A for b in A for c in A),key=lambda x:score(x[1]))
if __name__=='__main__': ct='ILQKDOEYYIKLTSUDOHHGGQVOHSWSMMEOSLUJLJFKPJFSRZMSLHJZPPRBMDVRZDOMBVSNEVAMDLKZXRCDWTDITCPWJRKABXCJFHGQDFPJYIMIBOZNPOBZSXFZOZSIRKJXXZOXIPFFUZBASSZNELPKMPKDODEUCMGPXUVFFNSWHFVXFKAMBZWOROCQSVPXUOBWSTDMWBPMLISHUCYLCRSVMZZQRMFQFOUCKRMZRHIPYOAXXCLCWPDIVLLFCLRIKPSRINMWVJDIBAGHJXZGRBFVGVKRNYTSYZLKTLPFHRCQAMLYCXNXTRMFRYVPKBPGYIENSRYZWTVFQDWGKIWMPKPFXLFPSMROFUZXEYRXBWDLOQHERQITSPOGCEWBVCGRFMLKNVAWAWICJPECNLDGYCXMCNRVRBDLXZHTMXHNUQBTBDWNKAHYOBFFPJXDGWUNWWDPYZQTYLXNNKRVLRYYOFSYLRATAKMPULGJCAEADBYDXWGBPXJAROBORAFMRLKJHYZRKZTCSZCGJFUHCHAILBLYETDUXFDXBGIOAVAOCKAJLOTFKAKGSRBIWOJPUHBFCMVVCQUDHUBNPZBVPRCIEKLDWFNEJOEPVGBSFPIKHVIABOPOXFSMMODYCUAPVPLAJHIRWCDPAFHAGHWFOXIGQGUULMQWNXVXDVNPWKEGUAFWHXLIFTYNLOYERRVZSNYTQLIDDDMHKUBGUCMVPAGGOIJIBBBKBALORYMOGVOTRRHHVPDVIYBJNELKRJJOUTCGOTIBUMBIAXDVTIEOMDCJEQRQNIAJJOUFLIHVECTUNADXUEILJPGFQUFJLJMKRRJIPOZURHZAHVMFBMUEUYGASUQFFVKZMUSBTMNATWUYQUEWFQTJTWFBQFZGCFCJTMEOHJGFFLHYAOVZETHFVRGLIJMDDZUV'; start,plain=break_enigma(ct); print('Recovered rotor start:',start); print(f"Best fit score: {score(plain):.2f}"); print(); print('Recovered plaintext:'); print(plain)
Two tricks make step and run work as pure expressions:
stepmutates through__setitem__, not assignment.pos[0] = ...is a statement and can’t live inside a lambda, butpos.__setitem__(0, ...)is a method call, an expression, that has the exact same effect. Bundling three of those calls into a tuple runs them left to right in order, exactly like three sequential assignment lines would. Indexing the result with[2]afterward just gives the lambda something to return; the mutation already happened, and the return value itself is never used. It’s also worth notingN1/N2(the two rotors’ notch positions) get computed once up front here. That’s instead of being recomputed by anA.index(...)call on every single step, the way thedef step(pos)version above does.runthrows away a value on purpose. Inside the generator expression,(step(pos), A[...])[1]builds a two-element tuple every iteration. Evaluating it left to right callsstep(pos)first, advancing the rotors right wherestep_rotors(positions)sits in the readable version. It then computes the actual output letter, and[1]selects only that second element. The rotor-stepping side effect happens as a byproduct of building a tuple that’s immediately thrown away except for its last element.
Both are standard (if slightly mischievous) functional-programming tricks for smuggling mutation past Python’s expression/statement divide. A lambda still can’t contain a for loop or an if/elif chain as statements, but it can call methods that have side effects, and a comprehension can iterate for it. Since the underlying rotor math, stepping logic, and scoring are byte-for-byte identical to the readable and ~15-line versions above, running this against the same demo ciphertext reproduces the exact same result. MFK and LEK tie at score -882.26, decrypting to the same passage quoted in the worked example. And, walking the identical A→Z nested a/b/c order as the version above, break_enigma here resolves the tie the same way too, returning LEK before MFK.
Interactive Visualizer
Try it above: paste any ciphertext produced by this simplified model (rotor order I-II-III, Reflector B, no plugboard), or use the default. Watch the rotor dials tick through all 17,576 candidate starting positions while a live leaderboard tracks the best-scoring decryptions found so far. When the search finishes, it highlights the winner, or, as with the default example, both tied winners when a double-stepping “phantom twin” is in play.
Simplified Enigma vs. the Other Breakers on This Site
| Caesar | Vigenère / Beaufort | Substitution | Enigma (simplified) | |
|---|---|---|---|---|
| Key space searched | 25 | 26^(key length) | 26! (≈ 4 × 10²⁶) | 17,576 |
| Search method | Exhaustive | Kasiski + IC to find length, then per-column exhaustive search | Hill-climbing / simulated annealing (key space too large to enumerate) | Exhaustive |
| Scoring signal | Common-word matches | Chi-squared letter frequency | Digram statistics + common-word bonus | Digram statistics only |
| Guaranteed global optimum? | Yes | Yes, per column | No, annealing can’t guarantee it | Yes |
| What’s assumed away | Nothing | Nothing | Nothing | Rotor order, plugboard, ring settings |
Enigma is the odd one out in an interesting way. Put on the same scale, this post’s reduced 17,576-key search sits between Caesar’s 25 and a 4-letter Vigenère key’s 456,976, comfortably brute-forceable. The full historical Enigma, at roughly 10²⁰, is actually smaller than Substitution’s 26! (≈ 4 × 10²⁶), yet nobody brute-forces Substitution either. Keyspace size alone was never the whole story for either cipher; what matters is whether some search strategy, statistical or logical, can actually crawl that space. Substitution’s 26! yields to hill-climbing because swapping two letters gives smooth, gradual feedback. Enigma’s 10²⁰ yields to nothing resembling brute force or hill-climbing at all, because a wrong plugboard guess looks exactly as wrong as a slightly-wrong one. That’s precisely why the real Bombe attacked the problem completely differently, as the full Bombe article covers. The entire difficulty of Enigma was never really about that 17,576. It was about the plugboard and rotor-order combinatorics stacked on top of it, and that’s what real cryptanalysis had to defeat.
FAQ
Is this actually how the wartime Enigma was broken?
No, and this post is explicit about that. The real attack used known-plaintext cribs and the Bombe’s diagonal-board trick to handle rotor order and plugboard uncertainty without brute-forcing either. This visualizer solves a genuinely smaller problem (rotor order and plugboard assumed known) and brute-forces the part of the key space (rotor starting position) that’s actually small enough to enumerate.
Why does the search sometimes return two tied “best” answers?
Because of Enigma’s double-stepping mechanism. If the true starting position has its middle rotor sitting exactly one letter past its own notch, there’s always a second starting position: one that lands on the notch itself. Its very first keystroke triggers a double-step landing on the identical rotor state the true key reaches on its next tick. From that point on the two keys produce byte-identical output for the rest of the message. So no scoring function, however good, can tell them apart from ciphertext alone. It’s a genuine ambiguity in the cipher, not a weakness in the search.
Why score with digrams only, and not common words like the Substitution or Caesar breakers?
Real Enigma traffic didn’t preserve word boundaries. Messages were transmitted as continuous letter streams, often broken into arbitrary 5-letter groups purely for radio operators’ convenience, not along word lines. A word-matching bonus would need spaces to anchor against. So this breaker relies entirely on digram statistics, which work identically whether or not spacing survives.
What would it take to model the plugboard here too?
Even a modest plugboard (say, up to 10 pairs) multiplies the search space by roughly 150 trillion. That’s where exhaustive search stops being viable and crib-based techniques like the real Bombe’s become necessary. A future post modeling the actual Bombe would need a known-plaintext crib, a “menu” graph built from it, and loop/contradiction detection, a different technique entirely from the brute-force approach used here.
How long does the ciphertext need to be for this to work reliably?
Similar to the other digram-based breakers on this site: shorter messages give the scorer less signal to distinguish the true key from every wrong one. They also increase the odds of stumbling into a coincidental high-scoring wrong key. The visualizer’s default example runs to 920 letters, comfortably long enough that the correct key’s score decisively separates from every candidate except its one double-stepping twin.
References
-
Wikipedia. “Enigma machine.” Available at: https://en.wikipedia.org/wiki/Enigma_machine
-
Wikipedia. “Enigma rotor details” (double-stepping mechanism). Available at: https://en.wikipedia.org/wiki/Enigma_rotor_details
-
Wikipedia. “Bombe.” Available at: https://en.wikipedia.org/wiki/Bombe
-
Copeland, B. Jack (ed.). “The Essential Turing.” Oxford University Press, 2004.
-
Kozaczuk, Władysław. “Enigma: How the German Machine Cipher Was Broken, and How It Was Read by the Allies in World War Two.” University Publications of America, 1984.
-
Sale, Tony. “The Bombe.” The National Museum of Computing / Bletchley Park technical notes. Available at: https://www.codesandciphers.org.uk/enigma/