Skip to main content
Basic Classic Symmetric Breakers Advanced

Breaking Enigma the Real Way: The Turing/Welchman Bombe

The site's simplified Enigma breaker fixes the rotor order, skips the plugboard, and brute-forces 17,576 positions by statistics. The real Bombe did none of that. It searched rotor order and position too, deduced the plugboard through pure logic, and never scored a single decryption.

PL
Pashalis Laoutaris
August 22, 2026
34 min read

Interactive Enigma Bombe Breaker

🔐 Enigma Bombe Breaker (Full Turing/Welchman Method)

7
Rotor order, starting position, AND plugboard are all unknown here. That's 6 orderings of Rotors I/II/III × 17,576 positions = 105,456 machine settings, each with up to 10¹⁴ possible plugboards. No plugboard brute force happens at all: the crib builds a "menu" graph, and a hypothesis for one letter's unsteckered value gets propagated through it (plus the diagonal board's reciprocal links) until it either survives intact or contradicts itself.
Enter text and click a button to start!

Step 1: The Menu

Every crib letter/cipher letter pair at each position becomes an edge in a graph. A menu is only useful to the Bombe if it contains at least one loop: a letter reachable from itself by more than one path. An acyclic menu can never produce a contradiction, for any hypothesis, at any setting.

Step 2: Searching Rotor Orders, Positions, and (Implicitly) the Plugboard

--
A
--
A
--
A
Not started.
Rotor Order Position # Surviving Hypotheses

Step 3: Recovered Plugboard and Decryption

OXXCPIGTFASZIWYRWPIODLWFMUEFBOAANWJTTYIVZLBCTDLUCICNAMMQQQYUHPNLKBJHFQYMXXVCUPXVDIJRUWNGYQLSQKWXHCKQOIJQGMEEYPCNAPJGCLHIJEYECEJMLNRDYPUEIKPATKFZBHCHWHWPPSYMZFGPLGQLZIVEULKDVOQXLZZKZMNZAQSMQCWIBNIZUDGZFDNIRDFQHXAWKDGGSLXYBQ

Breaking Enigma the Real Way: The Turing/Welchman Bombe

Introduction

This site’s simplified Enigma breaker is upfront about what it isn’t: rotor order fixed to I-II-III, Reflector B assumed, no plugboard modeled at all, brute-forcing only the 17,576 rotor starting positions and picking whichever decryption scores best against bigram statistics. That post’s own limitations section names the real thing directly: “a genuine Bombe/Turing-style attack… is a substantially larger undertaking,” and leaves it there. This post is that undertaking.

The real wartime Enigma key space wasn’t 17,576. With the plugboard included, it exceeded 10¹⁵⁸ for the full military machine: statistically brute-forceable by nothing that has ever existed. Turing’s insight, engineered into hardware by him and Gordon Welchman at Bletchley Park, wasn’t a faster search. It was a different kind of search entirely. Given a short guessed fragment of plaintext (a crib), most of the plugboard’s astronomical uncertainty turns out not to matter. A wrong rotor setting produces a logical contradiction almost immediately, regardless of what the plugboard is doing. No scoring function, no bigram statistics, no “best guess.” A setting either survives intact or it doesn’t.

Table of Contents

Why Statistics Don’t Scale to the Plugboard

The Playfair breaker hill-climbs over a 25! key square using quadgram statistics. The simplified Enigma breaker brute-forces 17,576 positions using bigram statistics. Both work because their key space, however large, is small enough that some search strategy (exhaustive or heuristic) can crawl it while a scoring function tells good candidates from bad ones.

The plugboard breaks that approach outright. Ten plugboard pairs chosen from 26 letters is 150,738,274,937,250 possible wirings, for one fixed rotor setting. Multiply by 17,576 positions and 60 rotor orderings (choosing 3 of 5 historical rotors, ordered) and the combined key space is the 10¹⁵⁸ figure above. No amount of statistical hill-climbing crawls a space that size. There’s nothing resembling a smooth “getting warmer” gradient to climb. A decryption with 9 of 10 plugboard pairs right looks like gibberish, no better-scoring than one with none right. Statistics need partial credit to work, and the plugboard doesn’t offer any.

What does work is refusing to search the plugboard at all. The Bombe never enumerates a single plugboard wiring. It searches rotor order and position exactly like the simplified breaker (60 × 17,576; this post’s version narrows that to the site’s existing 3-rotor set, 6 × 17,576 = 105,456, in the section on scope below). For each one, it asks a completely different question: is there any plugboard consistent with this crib at all? That question turns out to be decidable by pure logic, in the time it takes to walk a small graph.

The Crib and the Menu

A crib is a guessed plaintext fragment assumed to sit at a known position in the ciphertext. Bletchley’s cribs came from predictable message structure (weather reports, standard headers, even operator laziness like re-sending “KEINEBESONDERENEREIGNISSE,” nothing to report). This post’s crib is the classic training example in a lighter disguise: WEATHERREPORTFOLLOWSWEATHERREPOR, aligned at the very start of the message.

Line the crib up against the matching ciphertext letters, one pair per position:

position:   1  2  3  4  5  6  7  8  9 10 ...
crib:       W  E  A  T  H  E  R  R  E  P ...
cipher:     O  X  X  C  P  I  G  T  F  A ...

Each position is a fact: at this point in the message, with the machine in some specific (unknown) state, this crib letter became this cipher letter. Enigma’s plugboard is applied before and after an involution (self-inverse) scrambler permutation. That fact holds in both directions, regardless of which letter is “plaintext” and which is “ciphertext.” Treat every letter that appears (in the crib or the ciphertext slice) as a node, and every position as an edge connecting two nodes. That graph is the menu, and it’s the entire raw material the Bombe has to work with.

Why the Menu Needs a Loop

A tree-shaped menu (no letter reachable from itself by more than one path) is useless to the Bombe, no matter how many edges it has. Walk from any starting hypothesis through a tree, and every letter gets assigned exactly once. Nothing can ever conflict, because nothing is ever visited twice. Every single one of the 105,456 rotor settings would pass such a menu. The test would filter nothing at all.

A loop changes that completely. If node A connects back to itself through a cycle of several other nodes, then a hypothesis for A’s value gets propagated all the way around that cycle. It has to agree with itself at the end.

Two concrete loops from this post’s own menu make this less abstract. Lining up the crib against the ciphertext (as shown above) produces, among many others, these edges:

position 1:  W -- O
position 10: P -- A
position 11: O -- S
position 18: O -- P
position 20: S -- O
position 23: A -- W

Positions 11 and 20 alone already form the smallest possible loop: O → S (position 11), then S → O again (position 20). A hypothesis for O’s value has to survive being pushed through both edges and land back on itself. Positions 1, 18, 10, and 23 form a longer one: W → O → P → A → W, a 4-edge cycle that has to close consistently all the way around. Every wrong rotor setting has to get lucky on every one of these closures at once, which is exactly why a menu with 12 independent loops like this one is such a decisive filter. For the correct rotor setting, it does. For a wrong one, the composed permutation around the loop is, for cryptanalytic purposes, effectively random. A random permutation of 26 letters has no fixed point roughly 37% of the time. One loop alone is a weak filter. Bletchley codebreakers specifically hunted for cribs whose menus had several independent loops. Each additional loop that has to close consistently roughly multiplies the rejection rate. This post’s demo crib was chosen by literally the same method: searching candidate phrasings for the one whose menu has the most independent loops (its cyclomatic number, edges minus nodes plus one, for a connected graph). WEATHERREPORTFOLLOWSWEATHERREPOR, repeating the same phrase back to back, produces a menu with 12 independent loops across just 21 distinct letters. That’s an exceptionally rich menu, and exactly the reason that particular crib was picked over dozens of other candidates tried.

The Hypothesis Test: Turing’s Contribution

For one fixed rotor order and starting position, here’s the complete test:

  1. Pick a test letter: any node in the menu, ideally one with several edges (the visualizer picks the highest-degree node automatically).
  2. For each of the 26 possible values that letter’s unsteckered self could take (i.e., a hypothesis for the plugboard’s effect on it): assign that value, then propagate outward along every menu edge. Position i’s edge relation is exact and computable. It’s the same 3-rotor-plus-reflector permutation any Enigma visualizer on this site already implements, just evaluated at position i’s specific rotor state. It’s an involution, so the relation reads the same in either direction across the edge.
  3. If propagation ever reaches an already-assigned node with a different value than logic just implied, the hypothesis is contradicted. Discard it and try the next of the 26.
  4. If a hypothesis survives propagating through the entire menu with no contradiction, that hypothesis, and the rotor setting it survived under, is a stop.

This is graph BFS with a consistency check, nothing more exotic. What makes it powerful is what a contradiction means. Rejecting one hypothesis doesn’t just eliminate one plugboard guess. If none of the 26 possible values for the test letter survive, the entire rotor setting is rejected too.

The Diagonal Board: Welchman’s Contribution

Turing’s original design, run as described above, worked. But it left real power on the table, and testing it exposed exactly where. An early prototype run against this post’s own demo crib produced a subtly wrong decrypted letter, traced to a specific gap. The crib mentions the letters W, E, A, T, H, R, P, ... but never the letter N, yet the true plugboard happens to swap A and N. Turing’s menu-only propagation deduces S(A) = N (the crib does constrain A) but has no edge that ever mentions N. So it never separately deduces S(N) = A. Even though that’s not a new fact requiring more evidence, it’s the same fact. A plugboard connection is a physical cable; if it joins jack A to jack N, it joins N to A automatically. Missing that isn’t a small omission. Every letter is a potential silent gap, and any one of them left unlinked either weakens the contradiction test or, worse, leaves a wrong assumption unflagged in the final output.

Gordon Welchman’s diagonal board fixes this by wiring that reciprocity directly into the hardware. Whenever a hypothesis assigns S(x) = y, the board simultaneously asserts S(y) = x, whether or not y ever appears in the crib. If y happens to have its own menu edges, that reciprocal assignment can cascade into fresh propagation the crib alone would never have reached. This turned out to be exactly the missing piece. Adding it as a second edge type in the same propagation (implemented in assign in both the visualizer and the Python code below) fixed the wrong-letter bug immediately. It’s a documented, load-bearing part of why the operational Bombe worked as well as it did historically. It was Welchman’s own addition, layered on top of Turing’s original design, rather than a detail of Turing’s initial concept.

Put together: for every rotor order and every starting position, build the menu once (it doesn’t depend on the rotor setting), then run the 26-hypothesis test with diagonal-board propagation. A setting with zero surviving hypotheses is discarded outright. A setting with one or more surviving hypotheses is a stop, recorded along with whichever partial plugboard mapping each surviving hypothesis implies.

This post’s version searches the 6 orderings of the 3 rotors (I, II, III) already used everywhere else on this site into the 3 available slots, times 17,576 positions each: 105,456 total settings, each tested by the full menu-and-diagonal-board propagation above. See Limitations for why that’s a deliberate, documented scope choice rather than the full historical 60-ordering search.

A Worked Example

The demo ciphertext was generated with rotor order II-I-III, starting position E-R-C, and plugboard pairs WE AN LF HT. That’s deliberately not the simplified breaker’s usual I-II-III/all-A defaults, so the rotor-order search actually has something to find. Running the full search:

  • 105,456 settings tested.

  • 2 settings survive with a consistent hypothesis: not 1, and not a large ambiguous cluster, but a specific pair explained in the next section.

  • Both surviving settings share rotor order II-I-III and recover an identical partial plugboard: WE, AN, LF, HT steckered, with O, S, Y, P, I, D, X, U, R, B, G, Z, M, C confirmed unsteckered (22 of 26 letters accounted for, purely from a 32-letter crib).

    Stop 1 Stop 2
    Rotor order II-I-III II-I-III
    Start position D-Q-C E-R-C
    Recovered plugboard WE, AN, LF, HT WE, AN, LF, HT
    Unsteckered letters O,S,Y,P,I,D,X,U,R,B,G,Z,M,C O,S,Y,P,I,D,X,U,R,B,G,Z,M,C
    Full decryption matches truth? Yes, all 222 letters Yes, all 222 letters
  • Decrypting the full 222-character ciphertext with either recovered setting produces:

“WEATHER REPORT FOLLOWS WEATHER REPORT ENDS. THE BOMBE TESTS EVERY ROTOR ORDER AND EVERY STARTING POSITION, FOR A CRIB WITH A LOOP. ONLY ONE SETTING SURVIVES ACROSS ALL ONE HUNDRED FIVE THOUSAND FOUR HUNDRED FIFTY-SIX POSSIBLE KEYS, AND THAT SURVIVOR IS THE TRUE KEY.”

Exact, full match to the original plaintext: every one of the 222 letters, not just the 32 the crib covered.

A Genuine Enigma Quirk, Rediscovered: The Double-Stepping Tie

The simplified Enigma breaker’s guide already documents a real quirk of the double-stepping mechanism producing tied results. This breaker rediscovered the same underlying phenomenon independently, in a new form.

The two surviving settings are rotor order II-I-III at positions D-Q-C and E-R-C, adjacent in a very specific sense. Rotor I sits in the middle slot for this order, and its notch letter is Q. Position D-Q-C has the middle rotor sitting exactly at its own notch. Thanks to double-stepping, that forces the middle and left rotors to advance together on the very next keypress, regardless of what the right rotor is doing. Stepped once, D-Q-C becomes E-R-D. Position E-R-C, stepped once under completely ordinary rules (only the right rotor advances, since it’s not at its own notch yet), also becomes E-R-D. From that point on the two states are, not approximately, but exactly, the same machine.

Keypress Starting from D-Q-C Starting from E-R-C
Before any keypress D-Q-C E-R-C
After keypress 1 E-R-D (middle at notch Q forces middle + left to step together) E-R-D (ordinary step: only the right rotor advances)
After keypress 2 Identical from here on Identical from here on

Every subsequent letter, for the rest of any message of any length, encrypts identically under both. Verified directly: stepping both position sequences out to all 222 steps of this demo’s message shows the two states first coincide at step 1 and never diverge again.

This means D-Q-C and E-R-C aren’t a statistical near-tie the way the simplified breaker’s bigram scoring occasionally produces. They are, cryptographically, the same key, indistinguishable by any test applied to ciphertext alone, for as long as the message runs. The historical Bombe would have shown precisely this behavior: a genuine double-stop near a notch position, both entries equally valid, requiring no further disambiguation because there’s nothing left to disambiguate.

Python Implementation

The interactive visualizer above runs this exact algorithm in JavaScript. Here’s the same attack in Python, verified independently to produce identical output: same 2 stops, same recovered plugboard, same exact plaintext, same 61-second single-threaded runtime for the full 105,456-setting search. (The JavaScript version, run once in Node.js during development to validate the browser code, completed the same search in about 11-14 seconds. Python’s interpreter overhead per hypothesis-propagation step, run over 105,456 × 26 hypotheses, accounts for the difference. Both are doing identical work.)

Key Features

  • No scoring function anywhere. Every other statistical breaker on this site (Substitution, Playfair, the simplified Enigma breaker) ranks candidates by how English-like they look. This one only ever asks yes/no: does this hypothesis survive, or does it contradict itself?
  • The diagonal board is a second edge type, not a separate pass. assign is called for both the menu’s own scrambler-derived edges and the reciprocal plugboard fact in the same propagation loop. Welchman’s addition sits directly alongside Turing’s original mechanism rather than as a bolt-on afterward.
  • scrambler_at reuses genuine encryption math. It’s the same rotor-forward/rotor-backward/reflector composition any Enigma visualizer needs, evaluated at one specific position’s rotor state. Cryptanalysis here needs no additional linear algebra or statistics beyond what encryption itself already requires.

Code

# enigma_bombe.py
#
# A genuine Turing/Welchman Bombe: given a crib (a guessed plaintext
# fragment at a known offset) whose "menu" graph contains at least one
# loop, this searches every rotor order and starting position and, for
# each, tests all 26 possible unsteckered values of one menu letter by
# propagating them through the whole menu -- plus the diagonal board's
# reciprocal plugboard fact -- until either a contradiction kills the
# hypothesis or the whole menu closes consistently. No scoring, no
# statistics: a setting either survives or it doesn't.

A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

ROTORS = {
    'I':   {'wiring': 'EKMFLGDQVZNTOWYHXUSPAIBRCJ', 'notch': 'Q'},
    'II':  {'wiring': 'AJDKSIRUXBLHWTMCQGZNPYFVOE', 'notch': 'E'},
    'III': {'wiring': 'BDFHJLCPRTXVZNYEIWGAKMUSQO', 'notch': 'V'},
}
REFLECTOR = 'YRUHQSLDPXNGOKMIEBFZCWVJAT'
ROTOR_KEYS = ['I', 'II', 'III']


def rotor_forward(d, pos, i):
    shifted = (i + pos) % 26
    return (A.index(d['wiring'][shifted]) - pos) % 26


def rotor_backward(d, pos, i):
    shifted = (i + pos) % 26
    return (d['wiring'].index(A[shifted]) - pos) % 26


def step_positions(defs, positions):
    right_at_notch = positions[2] == A.index(defs[2]['notch'])
    middle_at_notch = positions[1] == A.index(defs[1]['notch'])
    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 scrambler_at(defs, start_positions, pos, letter_index):
    p = list(start_positions)
    for _ in range(pos):
        step_positions(defs, p)
    i = letter_index
    i = rotor_forward(defs[2], p[2], i)
    i = rotor_forward(defs[1], p[1], i)
    i = rotor_forward(defs[0], p[0], i)
    i = A.index(REFLECTOR[i])
    i = rotor_backward(defs[0], p[0], i)
    i = rotor_backward(defs[1], p[1], i)
    i = rotor_backward(defs[2], p[2], i)
    return i


def build_menu(crib, cipher_slice):
    return [(crib[i], cipher_slice[i], i + 1) for i in range(len(crib))]


def analyze_menu(edges):
    adj = {}
    for a, b, pos in edges:
        adj.setdefault(a, []).append((b, pos))
        adj.setdefault(b, []).append((a, pos))
    parent = {}

    def find(x):
        parent.setdefault(x, x)
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    has_cycle = False
    for a, b, _ in edges:
        ra, rb = find(a), find(b)
        if ra == rb:
            has_cycle = True
        else:
            parent[ra] = rb

    test_letter = max(adj, key=lambda k: len(adj[k]))
    return adj, has_cycle, test_letter


def assign(assigned, queue, letter, val):
    if letter not in assigned:
        assigned[letter] = val
        queue.append(letter)
        return True
    return assigned[letter] == val


def test_setting(defs, start_positions, adj, test_letter):
    consistent = []
    for h in range(26):
        assigned, queue = {}, []
        ok = assign(assigned, queue, test_letter, h)
        if ok:
            ok = assign(assigned, queue, A[h], A.index(test_letter))  # diagonal board

        qi = 0
        while qi < len(queue) and ok:
            cur = queue[qi]; qi += 1
            cur_val = assigned[cur]
            for to, pos in adj.get(cur, []):
                implied = scrambler_at(defs, start_positions, pos, cur_val)
                if not assign(assigned, queue, to, implied):
                    ok = False
                    break
                if not assign(assigned, queue, A[implied], A.index(to)):  # diagonal board
                    ok = False
                    break
        if ok:
            consistent.append((h, assigned))
    return consistent


def rotor_orders():
    for a in ROTOR_KEYS:
        for b in ROTOR_KEYS:
            for c in ROTOR_KEYS:
                if len({a, b, c}) == 3:
                    yield (a, b, c)


def run_machine(text, order, start_positions, plug_pairs):
    defs = [ROTORS[k] for k in order]
    positions = list(start_positions)
    plug_swap = lambda letter: plug_pairs.get(letter, letter)
    out = []
    for ch in text:
        step_positions(defs, positions)
        i = A.index(plug_swap(ch))
        i = rotor_forward(defs[2], positions[2], i)
        i = rotor_forward(defs[1], positions[1], i)
        i = rotor_forward(defs[0], positions[0], i)
        i = A.index(REFLECTOR[i])
        i = rotor_backward(defs[0], positions[0], i)
        i = rotor_backward(defs[1], positions[1], i)
        i = rotor_backward(defs[2], positions[2], i)
        out.append(plug_swap(A[i]))
    return ''.join(out)


def run_bombe(ciphertext, crib, offset=0):
    cipher_slice = ciphertext[offset:offset + len(crib)]
    edges = build_menu(list(crib), list(cipher_slice))
    adj, has_cycle, test_letter = analyze_menu(edges)
    if not has_cycle:
        raise ValueError('Menu has no loop -- pick a different crib.')

    stops = []
    for order in rotor_orders():
        defs = [ROTORS[k] for k in order]
        for a in range(26):
            for b in range(26):
                for c in range(26):
                    consistent = test_setting(defs, [a, b, c], adj, test_letter)
                    if consistent:
                        stops.append({'order': order, 'start': [a, b, c], 'hypotheses': consistent})
    return stops


if __name__ == '__main__':
    TRUE_ORDER = ('II', 'I', 'III')
    TRUE_START = [4, 17, 2]
    TRUE_PLUGS = {'W': 'E', 'E': 'W', 'T': 'H', 'H': 'T', 'A': 'N', 'N': 'A', 'L': 'F', 'F': 'L'}

    plaintext = ('WEATHERREPORTFOLLOWSWEATHERREPORTENDSXTHEBOMBETESTSEVERYROTORORDERAND'
                 'EVERYSTARTINGPOSITIONXFORACRIBWITHALOOPXONLYONESETTINGSURVIVESACROSS'
                 'ALLONEHUNDREDFIVETHOUSANDFOURHUNDREDFIFTYSIXPOSSIBLEKEYSXANDTHATSURV'
                 'IVORISTHETRUEKEYX')

    ciphertext = run_machine(plaintext, TRUE_ORDER, TRUE_START, TRUE_PLUGS)
    crib = plaintext[:32]
    stops = run_bombe(ciphertext, crib, 0)

    print(f'{len(stops)} stop(s) found:')
    for stop in stops:
        h, assigned = stop['hypotheses'][0]
        plug = {letter: A[val] for letter, val in assigned.items()}
        decrypted = run_machine(ciphertext, stop['order'], stop['start'], plug)
        pos_str = ''.join(A[p] for p in stop['start'])
        print(f"  order={''.join(stop['order'])} start={pos_str} matches_plaintext={decrypted == plaintext}")

Running this against the same demo ciphertext as the visualizer produces:

2 stop(s) found:
  order=IIIIII start=DQC matches_plaintext=True
  order=IIIIII start=ERC matches_plaintext=True

(''.join(stop['order']) prints II + I + III as one unbroken string. Both stops really do share rotor order II-I-III, exactly the double-stepping pair explained above.)

For Fun: The Same Thing in About 40 Lines

Same spirit as this site’s other golfed bonus sections: not for learning the algorithm from, just a demonstration of how compactly the whole menu-graph-plus-diagonal-board machinery collapses once every name is one or two letters. This is the densest golfed section on the site so far. The readable version above needed the most machinery of anything this site has broken (a graph, a union-find cycle check, BFS propagation with two edge types). All of it is still in here, just renamed.

import itertools as I;A='ABCDEFGHIJKLMNOPQRSTUVWXYZ';R={'I':('EKMFLGDQVZNTOWYHXUSPAIBRCJ','Q'),'II':('AJDKSIRUXBLHWTMCQGZNPYFVOE','E'),'III':('BDFHJLCPRTXVZNYEIWGAKMUSQO','V')};U='YRUHQSLDPXNGOKMIEBFZCWVJAT';K=list(R)
F=lambda d,p,i:(A.index(d[0][(i+p)%26])-p)%26;B=lambda d,p,i:(d[0].index(A[(i+p)%26])-p)%26
def S(d,p):p[0]=(p[0]+(p[1]==A.index(d[1][1])))%26;p[1]=(p[1]+((p[1]==A.index(d[1][1])) or (p[2]==A.index(d[2][1]))))%26;p[2]=(p[2]+1)%26
def C(d,s,n,i):
 p=list(s);[S(d,p) for _ in range(n)];return B(d[2],p[2],B(d[1],p[1],B(d[0],p[0],A.index(U[F(d[0],p[0],F(d[1],p[1],F(d[2],p[2],i)))]))))
M=lambda c,cs:[(c[i],cs[i],i+1) for i in range(len(c))]
def N(e):
 d={};[(d.setdefault(a,[]).append((b,p)),d.setdefault(b,[]).append((a,p))) for a,b,p in e];P={};f=lambda x:(P.setdefault(x,x),x if P[x]==x else f(P[x]))[1];h=0
 for a,b,_ in e:x,y=f(a),f(b);h|=x==y;P[x]=y
 return d,h,max(d,key=lambda k:len(d[k]))
def A2(m,q,l,v):return m[l]==v if l in m else (m.update({l:v}),q.append(l),1)[2]
def T(d,s,adj,l):
 out=[]
 for h in range(26):
  m,q={},[];ok=A2(m,q,l,h)
  if ok:ok=A2(m,q,A[h],A.index(l))
  qi=0
  while qi<len(q) and ok:
   cur=q[qi];qi+=1;cv=m[cur]
   for to,pos in adj.get(cur,[]):
    imp=C(d,s,pos,cv)
    if not(A2(m,q,to,imp) and A2(m,q,A[imp],A.index(to))):ok=0;break
  if ok:out.append((h,m))
 return out
def E(t,o,s,p):
 d=[R[k] for k in o];P=list(s);w=lambda x:p.get(x,x);O=[]
 for c in t:S(d,P);i=A.index(w(c));i=F(d[2],P[2],i);i=F(d[1],P[1],i);i=F(d[0],P[0],i);i=A.index(U[i]);i=B(d[0],P[0],i);i=B(d[1],P[1],i);i=B(d[2],P[2],i);O.append(w(A[i]))
 return ''.join(O)
def G(ct,cr,off=0):
 cs=ct[off:off+len(cr)];adj,cyc,tl=N(M(list(cr),list(cs)))
 if not cyc:raise ValueError('Menu has no loop -- pick a different crib.')
 stops=[]
 for order in I.permutations(K,3):
  d=[R[k] for k in order]
  for a,b,c in I.product(range(26),repeat=3):
   cons=T(d,[a,b,c],adj,tl)
   if cons:stops.append({'order':order,'start':[a,b,c],'hypotheses':cons})
 return stops
if __name__=='__main__':
 O=('II','I','III');ST=[4,17,2];PL={'W':'E','E':'W','T':'H','H':'T','A':'N','N':'A','L':'F','F':'L'};PT='WEATHERREPORTFOLLOWSWEATHERREPORTENDSXTHEBOMBETESTSEVERYROTORORDERANDEVERYSTARTINGPOSITIONXFORACRIBWITHALOOPXONLYONESETTINGSURVIVESACROSSALLONEHUNDREDFIVETHOUSANDFOURHUNDREDFIFTYSIXPOSSIBLEKEYSXANDTHATSURVIVORISTHETRUEKEYX';CT=E(PT,O,ST,PL);CR=PT[:32];res=G(CT,CR,0);print(f'{len(res)} stop(s) found:')
 for st in res:
  h,m=st['hypotheses'][0];plug={c:A[v] for c,v in m.items()};de=E(CT,st['order'],st['start'],plug);pos=''.join(A[p] for p in st['start']);print(f"  order={''.join(st['order'])} start={pos} matches_plaintext={de==PT}")

Run against the identical demo ciphertext, this produces byte-for-byte the same result as the readable version and the visualizer:

2 stop(s) found:
  order=IIIIII start=DQC matches_plaintext=True
  order=IIIIII start=ERC matches_plaintext=True

A guide to the renaming, since almost nothing here is self-explanatory at this density: F/B are rotor_forward/rotor_backward, S is step_positions, C is scrambler_at, M/N are build_menu/analyze_menu, A2 is assign (named to avoid shadowing the alphabet constant A), T is test_setting, E is run_machine, and G is run_bombe. The one piece worth calling out specifically is A2’s one-liner:

def A2(m,q,l,v):return m[l]==v if l in m else (m.update({l:v}),q.append(l),1)[2]

This one-liner does exactly what the readable version’s multi-line assign does: if the letter is already assigned, return whether the new value agrees with it (the contradiction check); if it isn’t, assign it, enqueue it for further propagation, and report success. The (m.update(...), q.append(...), 1)[2] tuple trick runs both side effects and then reaches for the literal 1 as the expression’s value. That’s purely because Python’s conditional expression needs something to evaluate to on that branch, and a bare statement won’t do. The two dictionary/list mutations are the actual point; the 1 is just there to make the ternary legal.

The Speed Version: Precomputed Tables and Multiprocessing

Both versions above call the equivalent of scrambler_at fresh for every letter propagated, during every one of the 26 hypotheses, at every one of the 105,456 settings. Each call re-steps the rotors from scratch. That’s correct, but it’s also the same small computation getting redone millions of times over. A reader submitted a version that restructures the identical algorithm (same menu, same loop-and-diagonal-board hypothesis test, same everything logically) around two changes that target exactly that redundancy. It’s fast enough that it’s worth walking through.

What Actually Changed

1. Precompute each rotor order’s entire state graph once. For a fixed rotor order there are only 17,576 possible (left, middle, right) positions. precompute_order builds two lookup tables covering all of them upfront:

  • next_state[state]: rotor stepping encoded as one integer transition per state. Advancing the rotors becomes a single array lookup instead of re-running the notch and double-stepping logic every time.
  • permutations[state]: the full 26-letter forward-reflect-backward scrambler permutation at that exact state, computed once instead of rebuilt on demand inside the search.

2. Compute “state after N steps from every possible start” for all 17,576 starts at once, for exactly the crib positions the menu actually needs, by stepping the whole array of states through next_state in lockstep. This replaces “step this one state N times” (repeated per hypothesis, per letter, per setting) with “step every state once, N times, in a single pass” per rotor order. It’s the same total amount of stepping work, just done once and reused, instead of recomputed from scratch at every one of the 17,576 starting positions individually.

3. Multiprocessing across rotor orders. The 6 rotor orderings are completely independent searches, so they run across up to 6 CPU cores at once via ProcessPoolExecutor. It falls back to sequential execution if multiprocessing isn’t available (some sandboxes and restricted hosts disallow spawning processes).

The Numbers

Run against this post’s own demo scenario, on the same machine that verified every other version on this page:

Version Time
Readable Python (this post’s main implementation) 61.5s
Golfed ~40-line version 46.8s
Speed version, single process 1.9s
Speed version, 6 worker processes 0.6s

The precomputed tables account for almost all of the win on their own: about a 25-33x speedup over the two naive versions, from algorithm and data-structure changes alone, before a single extra CPU core gets involved. Multiprocessing adds roughly another 3x on top of that, for close to 100x total against the readable version this post started with.

Code

# enigma_bombe_fast.py
import concurrent.futures
import os

A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'

ROTORS = {
    'I':   {'wiring': 'EKMFLGDQVZNTOWYHXUSPAIBRCJ', 'notch': 'Q'},
    'II':  {'wiring': 'AJDKSIRUXBLHWTMCQGZNPYFVOE', 'notch': 'E'},
    'III': {'wiring': 'BDFHJLCPRTXVZNYEIWGAKMUSQO', 'notch': 'V'},
}
REFLECTOR = 'YRUHQSLDPXNGOKMIEBFZCWVJAT'
ROTOR_KEYS = ['I', 'II', 'III']


def rotor_orders():
    for a in ROTOR_KEYS:
        for b in ROTOR_KEYS:
            for c in ROTOR_KEYS:
                if len({a, b, c}) == 3:
                    yield (a, b, c)


def run_machine(text, order, start_positions, plug_pairs):
    defs = [ROTORS[k] for k in order]
    positions = list(start_positions)
    plug_swap = lambda letter: plug_pairs.get(letter, letter)
    out = []

    w0 = [ord(c) - 65 for c in defs[0]['wiring']]
    w1 = [ord(c) - 65 for c in defs[1]['wiring']]
    w2 = [ord(c) - 65 for c in defs[2]['wiring']]
    b0 = [w0.index(i) for i in range(26)]
    b1 = [w1.index(i) for i in range(26)]
    b2 = [w2.index(i) for i in range(26)]
    reflector = [ord(c) - 65 for c in REFLECTOR]
    n1 = ord(defs[1]['notch']) - 65
    n2 = ord(defs[2]['notch']) - 65

    for ch in text:
        right_at_notch = positions[2] == n2
        middle_at_notch = positions[1] == n1
        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

        i = ord(plug_swap(ch)) - 65
        i = (w2[(i + positions[2]) % 26] - positions[2]) % 26
        i = (w1[(i + positions[1]) % 26] - positions[1]) % 26
        i = (w0[(i + positions[0]) % 26] - positions[0]) % 26
        i = reflector[i]
        i = (b0[(i + positions[0]) % 26] - positions[0]) % 26
        i = (b1[(i + positions[1]) % 26] - positions[1]) % 26
        i = (b2[(i + positions[2]) % 26] - positions[2]) % 26

        out.append(plug_swap(chr(i + 65)))
    return ''.join(out)


def _analyze_menu_int(edges):
    adj_list = [[] for _ in range(26)]
    seen = []
    for a, b, pos in edges:
        adj_list[a].append((b, pos))
        adj_list[b].append((a, pos))
        if a not in seen: seen.append(a)
        if b not in seen: seen.append(b)

    parent = {i: i for i in range(26)}

    def find(x):
        while parent[x] != x:
            parent[x] = parent[parent[x]]
            x = parent[x]
        return x

    has_cycle = False
    for a, b, _ in edges:
        ra, rb = find(a), find(b)
        if ra == rb:
            has_cycle = True
        else:
            parent[ra] = rb

    test_letter = max(seen, key=lambda k: len(adj_list[k])) if seen else 0
    return adj_list, has_cycle, test_letter


def precompute_order(order):
    defs = [ROTORS[k] for k in order]
    fw_tables, bw_tables = [], []
    for r in range(3):
        fw = [[0] * 26 for _ in range(26)]
        bw = [[0] * 26 for _ in range(26)]
        wiring = [ord(c) - 65 for c in defs[r]['wiring']]
        for pos in range(26):
            for i in range(26):
                fw[pos][i] = (wiring[(i + pos) % 26] - pos) % 26
                bw[pos][i] = (wiring.index((i + pos) % 26) - pos) % 26
        fw_tables.append(fw)
        bw_tables.append(bw)

    reflector = [ord(c) - 65 for c in REFLECTOR]
    n1 = ord(defs[1]['notch']) - 65
    n2 = ord(defs[2]['notch']) - 65

    next_state = [0] * 17576
    permutations = [None] * 17576

    for p0 in range(26):
        t0, b0 = fw_tables[0][p0], bw_tables[0][p0]
        for p1 in range(26):
            t1, b1 = fw_tables[1][p1], bw_tables[1][p1]
            middle_at_notch = (p1 == n1)
            np0 = (p0 + 1) % 26 if middle_at_notch else p0
            np1_if_mid = (p1 + 1) % 26 if middle_at_notch else p1

            for p2 in range(26):
                t2, b2 = fw_tables[2][p2], bw_tables[2][p2]
                state = p0 * 676 + p1 * 26 + p2

                np1 = np1_if_mid
                right_at_notch = (p2 == n2)
                if not middle_at_notch and right_at_notch:
                    np1 = (p1 + 1) % 26
                np2 = (p2 + 1) % 26
                next_state[state] = np0 * 676 + np1 * 26 + np2

                perm = [0] * 26
                for i in range(26):
                    c = t2[i]
                    c = t1[c]
                    c = t0[c]
                    c = reflector[c]
                    c = b0[c]
                    c = b1[c]
                    c = b2[c]
                    perm[i] = c
                permutations[state] = tuple(perm)

    return next_state, permutations


def _test_setting_int(adj_list, start_state, state_after_pos, permutations, test_letter):
    consistent = []
    for h in range(26):
        assigned = [-1] * 26
        queue = [0] * 26
        q_head = q_tail = 0

        assigned[test_letter] = h
        queue[q_tail] = test_letter
        q_tail += 1

        if assigned[h] == -1:
            assigned[h] = test_letter
            queue[q_tail] = h
            q_tail += 1
        elif assigned[h] != test_letter:
            continue

        ok = True
        while q_head < q_tail:
            cur = queue[q_head]
            q_head += 1
            cur_val = assigned[cur]

            for to, pos in adj_list[cur]:
                st = state_after_pos[pos][start_state]
                implied = permutations[st][cur_val]

                if assigned[to] == -1:
                    assigned[to] = implied
                    queue[q_tail] = to
                    q_tail += 1
                elif assigned[to] != implied:
                    ok = False
                    break

                if assigned[implied] == -1:
                    assigned[implied] = to
                    queue[q_tail] = implied
                    q_tail += 1
                elif assigned[implied] != to:
                    ok = False
                    break

            if not ok:
                break
        if ok:
            consistent.append((h, assigned))
    return consistent


def check_order(args):
    order, adj_list, required_poses, test_letter = args
    next_state, permutations = precompute_order(order)

    max_pos = max(required_poses) if required_poses else 0
    state_after_pos = [None] * (max_pos + 1)

    current_states = list(range(17576))
    if 0 in required_poses:
        state_after_pos[0] = list(current_states)
    for pos in range(1, max_pos + 1):
        current_states = [next_state[s] for s in current_states]
        if pos in required_poses:
            state_after_pos[pos] = list(current_states)

    stops = []
    for start_state in range(17576):
        consistent = _test_setting_int(adj_list, start_state, state_after_pos, permutations, test_letter)
        if consistent:
            a, b, c = start_state // 676, (start_state // 26) % 26, start_state % 26
            converted = [
                (h, {chr(idx + 65): val for idx, val in enumerate(assigned) if val != -1})
                for h, assigned in consistent
            ]
            stops.append({'order': order, 'start': [a, b, c], 'hypotheses': converted})
    return stops


def run_bombe(ciphertext, crib, offset=0):
    cipher_slice = ciphertext[offset:offset + len(crib)]
    edges = [(ord(crib[i]) - 65, ord(cipher_slice[i]) - 65, i + 1) for i in range(len(crib))]

    adj_list, has_cycle, test_letter = _analyze_menu_int(edges)
    if not has_cycle:
        raise ValueError('Menu has no loop -- pick a different crib.')

    required_poses = list({pos for _, _, pos in edges})
    tasks = [(order, adj_list, required_poses, test_letter) for order in rotor_orders()]

    workers = min(6, os.cpu_count() or 1)
    stops = []
    try:
        if workers > 1:
            with concurrent.futures.ProcessPoolExecutor(max_workers=workers) as executor:
                for res in executor.map(check_order, tasks):
                    stops.extend(res)
        else:
            for task in tasks:
                stops.extend(check_order(task))
    except Exception:
        # Multiprocessing isn't available in every environment (some
        # sandboxes and restricted hosts disallow spawning processes) --
        # fall back to running the same six precomputed searches in-process.
        stops = []
        for task in tasks:
            stops.extend(check_order(task))

    return stops


if __name__ == '__main__':
    import time
    TRUE_ORDER = ('II', 'I', 'III')
    TRUE_START = [4, 17, 2]
    TRUE_PLUGS = {'W': 'E', 'E': 'W', 'T': 'H', 'H': 'T', 'A': 'N', 'N': 'A', 'L': 'F', 'F': 'L'}

    plaintext = ('WEATHERREPORTFOLLOWSWEATHERREPORTENDSXTHEBOMBETESTSEVERYROTORORDERAND'
                 'EVERYSTARTINGPOSITIONXFORACRIBWITHALOOPXONLYONESETTINGSURVIVESACROSS'
                 'ALLONEHUNDREDFIVETHOUSANDFOURHUNDREDFIFTYSIXPOSSIBLEKEYSXANDTHATSURV'
                 'IVORISTHETRUEKEYX')

    ciphertext = run_machine(plaintext, TRUE_ORDER, TRUE_START, TRUE_PLUGS)
    crib = plaintext[:32]

    t0 = time.time()
    stops = run_bombe(ciphertext, crib, 0)
    elapsed = time.time() - t0

    print(f'elapsed: {elapsed:.3f}s')
    print(f'{len(stops)} stop(s) found:')
    for stop in stops:
        h, assigned = stop['hypotheses'][0]
        plug = {letter: A[val] for letter, val in assigned.items()}
        decrypted = run_machine(ciphertext, stop['order'], stop['start'], plug)
        pos_str = ''.join(A[p] for p in stop['start'])
        print(f"  order={''.join(stop['order'])} start={pos_str} matches_plaintext={decrypted == plaintext}")

Run against the same demo ciphertext as every other version on this page:

elapsed: 0.633s
2 stop(s) found:
  order=IIIIII start=DQC matches_plaintext=True
  order=IIIIII start=ERC matches_plaintext=True

Identical result to the readable version, the golfed version, and the interactive visualizer: the same double-stepping pair, the same recovered plugboard, the same full decryption, just reached in a fraction of a second instead of the better part of a minute.

Interactive Visualizer

Try it above: build the menu from the default crib (or your own; the visualizer will tell you immediately if your choice has no loop and can’t be used), then run the search. Watch the rotor dials cycle through all 6 orderings and 17,576 positions each, with any stop appearing in the table the moment it’s found. The final panel shows every surviving setting’s recovered plugboard pairs and full decryption side by side.

The Full Bombe vs. This Site’s Simplified Breaker

Simplified Enigma breaker Full Bombe (this breaker)
Rotor order Fixed: I-II-III Searched: all 6 orderings of I/II/III
Plugboard Not modeled at all Deduced via crib + menu + diagonal board
Method Brute force + bigram scoring Pure logical elimination, no scoring
What “correct” looks like Highest-scoring candidate (occasionally a near-tie) The setting(s) that don’t contradict themselves
Requires known/guessed plaintext? No, ciphertext-only Yes, a crib with a looped menu
Settings tested 17,576 105,456
Ring settings searched? No No
Result confidence Statistical best guess Exact: either a setting survives or it’s eliminated

The contrast mirrors the one this site already drew for the Hill cipher breaker: a known-plaintext attack that solves algebraically beats ciphertext-only statistics whenever the structure allows it. Hill’s structure was linearity. Enigma’s is the plugboard’s involution property, wired directly into a search that never needs to guess the plugboard at all.

Limitations of This Attack

Rotor set scoped to this site’s existing 3 rotors. The historical military Enigma offered 5 rotors (choose 3, ordered: 60 combinations) or more in some services. This breaker searches the 6 orderings of the same I/II/III set the encryption-side visualizer and the simplified breaker already use, for direct consistency with the rest of the site. The core algorithm is identical regardless of how many rotor types are available. Extending it to 5 rotors is purely a matter of adding their wiring tables and iterating 60 orderings instead of 6, at proportionally more runtime, not a different technique.

Ring settings (Ringstellung) are not searched. Real Bletchley operations determined these separately (through techniques like Banburismus, outside this post’s scope) before running a Bombe. This breaker assumes they’re already known and fixed, exactly as the simplified breaker does.

Requires a crib with a looped menu. An attacker with ciphertext alone and no plaintext guess has nothing to feed this technique. That’s precisely the gap the simplified breaker fills instead, at the cost of needing a fixed rotor order and no plugboard. Choosing a good crib (one whose menu has multiple independent loops, not just one) is itself real cryptanalytic work. This post’s demo crib was found by literally searching candidate phrasings for the highest cyclomatic number, the same process Bletchley codebreakers did by hand.

A stop can still be ambiguous or partial. This demo’s crib was rich enough to fully determine 22 of 26 letters’ steckering. A shorter or less-connected crib can leave more letters undetermined, or, more importantly, a menu with too few independent loops can leave multiple rotor settings surviving with no way to distinguish them from the menu alone. Historically, this is exactly why Bletchley’s process didn’t end at a Bombe stop: a “checking machine” pass against a longer ciphertext, filling in remaining plugboard letters by hand until the rest of the decryption read as real language, was still standard follow-up work. That’s the same step this breaker’s decrypted-output panel is doing automatically by simply leaving unrecovered letters unswapped.

FAQ

Why doesn’t this breaker score candidates the way the simplified one does?

Because the plugboard makes scoring useless here. A decryption with 9 of 10 plugboard pairs correct looks like unreadable noise, exactly as unreadable as one with none correct. There’s no smooth “getting warmer” signal for a scorer to climb. The Bombe sidesteps the plugboard’s search space entirely by asking a yes/no logical question (does any plugboard make this rotor setting self-consistent against the crib?) instead of ranking guesses.

What exactly does the diagonal board add that the menu alone doesn’t have?

Reciprocity. If a hypothesis implies the plugboard swaps letter A for letter N, that’s already true in both directions. A cable joining two jacks doesn’t have a direction. The menu’s own edges only encode facts the crib literally mentions. The diagonal board additionally asserts the reverse of every fact the moment it’s derived, even for letters (like N in this post’s worked example) that never appear anywhere in the crib text itself.

Why did the search find 2 stops instead of exactly 1?

Because they’re not actually two different keys. See the double-stepping section above. Positions D-Q-C and E-R-C, under rotor order II-I-III, step to the identical machine state after just one keypress and stay identical for the rest of any message. That happens because the middle rotor sits exactly at its own notch in one of them. This is a real, verifiable property of Enigma’s mechanical stepping, not a bug or an ambiguous result requiring a tiebreaker.

Could this breaker return zero stops even with a correct crib?

Yes, in two realistic ways. If the crib is misaligned with the actual ciphertext (wrong offset, or simply the wrong guess), every setting will contradict itself. That’s because the “correct” rotor setting, tested against a wrong crib, is just as inconsistent as a wrong setting tested against a right one. It can also happen with a technically correct crib whose alignment happens to land differently than assumed. Real Bletchley operators tried multiple plausible offsets for exactly this reason.

How does this compare to the Hill cipher breaker’s known-plaintext attack?

Both need the attacker to already know or guess some plaintext, and both replace brute-force search with something closer to solving. Hill’s K = C·P⁻¹ is literally algebra: one exact answer, computed directly. This Bombe is closer to constraint propagation over a graph: potentially several equally exact answers (as the double-stepping tie shows), found by elimination rather than direct computation. But it shares the same underlying idea: known structure turns search into something much more precise.

Is this genuinely how Bletchley Park broke Enigma?

The core logic (menu, loop, hypothesis propagation, diagonal board) is the real historical method, not a loose inspiration. What’s scoped down for this post is scale: 3 rotors instead of up to 5+, no separate ring-setting search, and a hand-picked high-quality crib rather than the intelligence-gathering and crib-hunting operation that was Bletchley’s actual day-to-day work. The algorithm itself, run against those simplifications, is the genuine Turing/Welchman technique.

References

  1. Wikipedia. “Cryptanalysis of the Enigma.” Available at: https://en.wikipedia.org/wiki/Cryptanalysis_of_the_Enigma

  2. Wikipedia. “Bombe.” Available at: https://en.wikipedia.org/wiki/Bombe

  3. Copeland, B. Jack (ed.). “The Essential Turing.” Oxford University Press, 2004. Includes Turing’s own 1940 “Treatise on the Enigma” (“Prof’s Book”).

  4. Welchman, Gordon. “The Hut Six Story: Breaking the Enigma Codes.” McGraw-Hill, 1982.

  5. Carter, Frank. “The Turing Bombe.” The Bletchley Park Trust Reports, No. 10.

  6. Sale, Tony. “The Bletchley Park 1944 Cryptographic Dictionary” (reprint), including contemporary descriptions of menus and diagonal board operation. Available at: https://www.codesandciphers.org.uk/