neural-swarm-1
Acta Machina

Bletchley Park's Legacy in Code

Reimplementing the Enigma and Bombe machines using Python.

Abstract painting by Aurelius Wendelken

By

In October, I took a drive from the Cotswolds to Bletchley Park, the central site of the UK’s code breaking efforts during World War II. While the main mansion was closed for an upcoming AI Safety Summit, the rest of the site was open and it was striking to see the plain huts where such historically significant events unfolded.

Throughout the park, there was a noticeable sense of practicality and focus, a keep calm and carry on attitude that was pervasive. This was especially evident in a 1942 security warning I saw, that staff had to sign when joining up. The notice, which had “SECRECY” written at the top in large font, highlighted an intense privateness and caution that was a daily part of life at Bletchley Park.

Alan Turing's office in Hut 8
Alan Turing's office in Hut 8

In recent times, Alan Turing’s legacy has been revived in the public consciousness. He received a posthumous apology from the British Prime Minister for the historical mistreatment due to his sexuality, and was honored on the £50 note introduced in 2021. As AI and Large Language Models become more prevalent, Turing’s concept of the Turing Test — a scenario where humans discern between responses from a human and a machine — is increasingly cited in media discussions.

Before his groundbreaking work on Turing Machines and the Turing Test, Turing was a key figure at Bletchley Park, a story that was depicted in the film, “The Imitation Game.” This blog post focuses on Turing’s development of the Bombe machine, a crucial tool in deciphering the codes of the Enigma machine.

Understanding the Enigma Machine

The Enigma machine was used for encrypting and decrypting messages and was employed extensively by Nazi Germany during World War II. It was created by Arthur Scherbius as a commercial cipher machine and, with a number of modifications for military use, it was considered highly secure at the time.

Mechanics of the Enigma Machine

The Enigma machine resembled a complex typewriter. It consisted of a keyboard, a set of rotating discs or rotors, a plugboard, and a lampboard. When a letter was typed, the signal was passed through the rotors, each providing a layer of encryption, reflected, and then passed back through again, before lighting up a different letter on the lampboard. This substitution process was the machine’s primary encryption method.

Enigma: Electromechnical Encryption Machine
Enigma: Electromechnical Encryption Machine

Rotor

Key to the Enigma’s complexity were the rotors. Each rotor was essentially a disk with electrical contacts on both sides and could be set to one of 26 positions. These contacts were connected in a specific, non-sequential manner. When a key was pressed on the Enigma’s keyboard two things would happen: an electrical signal was sent through the rotors changing the letter and the right-most rotor would rotate by one position, altering the encryption with each keystroke. This meant that the same letter pressed at different times would result in different letters being lit up.

class Rotor:
    def __init__(self, wiring, notch, ring_setting=1, position='A'):
        """
        Initialize the rotor with wiring, notch, ring setting, and initial position.
        :param wiring: A string representing the internal wiring of the rotor.
        :param notch: The letter at which this rotor causes the next rotor to advance.
        :param ring_setting: The setting of the ring (1-26), offseting the wiring.
        :param position: The initial position of the rotor (A-Z).
        """
        self.wiring = wiring
        self.notch = notch
        self.ring_setting = ring_setting - 1  # switch to zero-indexed
        self.position = ALPHABET.index(position)

    def rotate(self):
        """
        Rotate the rotor by one position.
        :return: True if the rotor is rotating past its notch position, otherwise False.
        """
        turnover = self.is_at_notch()
        self.position = (self.position + 1) % 26
        return turnover

    def is_at_notch(self):
        """
        Check if the rotor is at its notch position.
        :return: True if at notch position, otherwise False.
        """
        return ALPHABET[self.position] == self.notch
    
    def forward(self, input_letter):
        """
        Translate a letter through the rotor in the forward direction.
        :param input_letter: The letter to be translated.
        :return: The translated letter.
        """
        shifted_index = (ALPHABET.index(input_letter) + self.position - self.ring_setting) % 26
        wiring_letter = self.wiring[shifted_index]
        output_index = (ALPHABET.index(wiring_letter) - self.position + self.ring_setting) % 26
        return ALPHABET[output_index]

    def backward(self, input_letter):
        """
        Translate a letter through the rotor in the backward direction.
        :param input_letter: The letter to be translated.
        :return: The translated letter.
        """
        input_index = ALPHABET.index(input_letter)
        shifted_letter = ALPHABET[(input_index + self.position - self.ring_setting) % 26]
        wiring_index = self.wiring.index(shifted_letter)
        output_letter = ALPHABET[(wiring_index - self.position + self.ring_setting) % 26]
        return output_letter

Enigma I

The plugboard added another layer of complexity. By connecting letters in pairs, the machine would first swap those letters before passing them through the rotors, and then swap them again after they exited the rotors, making the encryption process even more intricate.

ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"

class Enigma:
    def __init__(
        self,
        rotor_order=[1, 2, 3],
        ring_settings=[1, 1, 1],
        reflector="B",
        plugboard=[],
        message_key="AAA",
    ):
        """
        Initialize the Enigma I machine with rotors, reflector, and plugboard.
        :param rotor_order: List of rotor types (1 to 5) in the order to be used.
        :param ring_settings: List of integers for ring settings of each rotor (1-26).
        :param reflector: A string (A-C) representing the type of reflector.
        :param plugboard: List of plug pairs for plugboard connections.
        :param message_key: A string denoting the initial positions of the rotors.
        """
        self.double_step = False
        self.set_plugboard(plugboard)

        rotors = {
            1: ("EKMFLGDQVZNTOWYHXUSPAIBRCJ", "Q"),
            2: ("AJDKSIRUXBLHWTMCQGZNPYFVOE", "E"),
            3: ("BDFHJLCPRTXVZNYEIWGAKMUSQO", "V"),
            4: ("ESOVPZJAYQUIRHXLNFTGKDCMWB", "J"),
            5: ("VZBRGITYUPSDNHLXAWMJQOFECK", "Z"),
        }
        self.rotors = [
            Rotor(*rotors[rotor], ring_setting, position)
            for rotor, ring_setting, position in zip(
                rotor_order, ring_settings, message_key
            )
        ]

        reflectors = {
            "A": "EJMZALYXVBWFCRQUONTSPIKHGD",
            "B": "YRUHQSLDPXNGOKMIEBFZCWVJAT",
            "C": "FVPJIAOYEDRZXWGCTKUQSBNMHL",
        }
        self.reflector = reflectors[reflector]

    def set_plugboard(self, plugboard):
        """
        Set the plugboard
        :param plugboard: List of plug pairs for plugboard connections.
        """
        self.plugboard = {}

        for plug1, plug2 in plugboard:
            self.plugboard[plug1] = plug2
            self.plugboard[plug2] = plug1

    def get_message_key(self):
        """
        Retrieve the message key.
        :return: A string denoting the positions of the rotors.
        """
        return "".join([ALPHABET[rotor.position] for rotor in self.rotors])
    
    def set_message_key(self, message_key):
        """
        Set the message key.
        :param message_key: A string denoting the initial positions of the rotors.
        """
        for rotor, position in zip(self.rotors, message_key):
            rotor.position = ALPHABET.index(position)

    def rotate_rotors(self, double_stepping=True):
        """
        Rotate the rotors, advancing the next rotor when a notch is reached
        :param double_stepping: Whether to apply double-stepping of the middle rotor.
        """
        left_rotor, middle_rotor, right_rotor = self.rotors

        left_rotor_turnover = False
        middle_rotor_turnover = right_rotor.rotate()

        if double_stepping and self.double_step:
            self.double_step = False
            left_rotor_turnover = middle_rotor.rotate()

        if middle_rotor_turnover:
            left_rotor_turnover = middle_rotor.rotate()
            if double_stepping and middle_rotor.is_at_notch():
                self.double_step = True

        if left_rotor_turnover:
            left_rotor.rotate()

    def encode_letter(self, letter, rotate):
        """
        Encode a single letter through the rotors, reflector, and plugboard.
        :param letter: The letter to encode.
        :param rotate: Whether to rotate rotors before encoding.
        :return: Encoded letter.
        """
        if rotate:  # rotate rotors before encoding
            self.rotate_rotors()

        letter = self.plugboard.get(letter, letter)  # apply plugboard

        for i in [2, 1, 0]:  # pass the letter through each rotor
            letter = self.rotors[i].forward(letter)

        letter = self.reflector[ALPHABET.index(letter)]  # reflect the letter

        for i in [0, 1, 2]:  # pass the letter back through the rotors in reverse order
            letter = self.rotors[i].backward(letter)

        letter = self.plugboard.get(letter, letter)  # apply plugboard settings again
        return letter

    def encode(self, message, rotate=True):
        """
        Encode a message using the Enigma I machine.
        :param message: The message to encode.
        :param rotate: Whether to rotate rotors while encoding.
        :return: Encoded message.
        """
        return "".join(self.encode_letter(letter, rotate) for letter in message)

Encrypting a Message

To encrypt a message, the operator would first set the day key according to the supplied monthly keysheet:

OKH-Maschinenschlüssel A Nr. 39
Datum Walzenlage Ringstellung Steckerverbindungen Kenngruppen
31. V II IV 17 09 02 KT AJ IV UR NY HZ GD XF PB CQ sfy azy zkq bqi
30. I III V 22 12 10 UE PL AY TB ZH WM OJ DC KN SI iuy swz omo myj
29. V IV II 04 01 25 WJ VD PO MQ FX ZR NE LG UC BK rui kao fqi rwu
28. II III IV 05 03 12 HR TJ LD IO CN GX QK PZ WS AF ioy kjv yko fpz
27. I II III 10 20 15 AQ ZK MU GH ST LN XY IJ BF RV ggf jus lrs glc
27. I II III 10 20 15 AQ ZK MU GH ST LN XY IJ BF RV ggf jus lrs glc

You would look up the current day of the month (Datum) and then make note of the rotor order (Walzenlage), ring setting (Ringstellung) and the plugboard settings (Steckerverbindungen). The indicator groups (Kenngruppen) are used to identify which day’s setting to use, which is important around midnight, when the keys are swapped over.

Choosing Day 31 (first row) from the keysheet:

enigma = Enigma(
    rotor_order=[5, 2, 4],
    ring_settings=[17, 9, 2],
    plugboard=["KT", "AJ", "IV", "UR", "NY", "HZ", "GD", "XF", "PB", "CQ"],
)

Then pick two random sets of three letters (trigrams): a start position and a message key. Move the rotors to the start position and type in the message key once. This gives the encrypted message key. Then, the encrypt a message, set the start position to the message key and then type out the message. Let’s assume the random trigrams we generated are: “ABC” for the start position and “XYZ” for the message key, and that the plaintext of the message we want to send is “ATTACKXATXDAWN” (attack at dawn).

enigma.set_message_key("ABC")
print(enigma.encode("XYZ"))
enigma.set_message_key("XYZ")
print(enigma.encode("ATTACKXATXDAWN"))
HLP
KXZDOWFQZOBMUZ

We transmit the start position and encrypted message key, followed by the ciphertext: “ABC HLP KXZDOWFQZOBMUZ”

Decrypting a Message

The receiver will also have a machine set up with the day key:

enigma = Enigma(
    rotor_order=[5, 2, 4],
    ring_settings=[17, 9, 2],
    plugboard=["KT", "AJ", "IV", "UR", "NY", "HZ", "GD", "XF", "PB", "CQ"],
)

Setting the start position to the first three letters of the received message and then typing the encrypted message key reveals the original message key:

enigma.set_message_key("ABC")
print(enigma.encode("HLP"))
XYZ

Setting the message key and typing in the rest of the encrypted message reveals the plaintext:

enigma.set_message_key("XYZ")
print(enigma.encode("KXZDOWFQZOBMUZ"))
ATTACKXATXDAWN

Perceived Security

The Enigma machine’s perceived security lay in its vast number of possible settings. With three rotors, each having 26 positions, and the additional complexity from the plugboard, the number of potential configurations was astronomical, making brute-force decryption seemingly impossible at the time.

To understand the number of settings that need to be tried:

  • choosing three rotors out of a possible five \({5 \choose 3} = 60\)
  • setting three rotor positions \(26 \cdot 26 \cdot 26 = 17,576\)
  • connecting 10 plugboard cables \(\frac{26!}{(26 - 2 \cdot 10)!} \cdot \frac{1}{2^{10} 10!} = 150,738,274,937,250\)

Multiplying these three numbers results in a staggering 158,962,555,217,826,360,000 different settings.

Fun Fact 1: The quote from The Imitation Game vastly understates this figure: “if we had 10 men checking one setting a minute, for 24 hours every day and seven days every week, it would take… [20 million years]”. In fact, it would take more than 30 trillion years.

Fun Fact 2: Having 10 plugboard cables creates more combinations than having all the letters paired up with 13 plugboard cables. In fact, having 11 plugboard cables result in the largest number of combinations.

Examples

We check that our Enigma works by running through some known plaintext and ciphertext examples.

# Default test

enigma = Enigma(
    rotor_order=[1, 2, 3],
    ring_settings=[1, 1, 1],
    reflector="B",
    plugboard=[],
    message_key="AAA",
)
assert enigma.encode("A" * 50) == "BDZGOWCXLTKSBTMCDLPBMUQOFXYHCXTGYJFLINHNXSHIUNTHEO"
# Rotor order test

enigma = Enigma(
    rotor_order=[3, 2, 1],
    ring_settings=[1, 1, 1],
    reflector="B",
    plugboard=[],
    message_key="AAA",
)
assert enigma.encode("A" * 50) == "FTZMGISXIPJWGDNJJCOQTYRIGDMXFIESRWZGTOIUIEKKDCSHTP"
# Ring setting test

enigma = Enigma(
    rotor_order=[1, 2, 3],
    ring_settings=[1, 1, 2],
    reflector="B",
    plugboard=[],
    message_key="AAA",
)
assert enigma.encode("A" * 50) == "UBDZGOWCXLTKSBTMCDLPBTUQOFXYHCXTGYJFLINHNXSHIUNGHE"
# Reflector test (Enigma Instruction Manual, 1930)

enigma = Enigma(
    rotor_order=[2, 1, 3],
    ring_settings=[24, 13, 22],
    reflector="A",
    plugboard=["AM", "FI", "NV", "PS", "TU", "WZ"],
    message_key="ABL",
)

plaintext = "FEINDLIQEINFANTERIEKOLONNEBEOBAQTETXANFANGSUEDAUSGANGBAERWALDEXENDEDREIKMOSTWAERTSNEUSTADT"
ciphertext = "GCDSEAHUGWTQGRKVLFGXUCALXVYMIGMMNMFDXTGNVHVRMMEVOUYFZSLRHDRRXFJWCFHUHMUNZEFRDISIKBGPMYVXUZ"

assert enigma.encode(plaintext) == ciphertext
# Plugboard test

enigma = Enigma(
    rotor_order=[1, 2, 3],
    ring_settings=[1, 1, 1],
    reflector="B",
    plugboard=["AB", "CD"],
    message_key="AAA",
)
assert enigma.encode("A" * 50) == "BJLDSYJIFKIFBEHPLXUBNLMLWICYWKXQKROOLCWQQKJZYRYNQK"
# Message key and double stepping test

enigma = Enigma(
    rotor_order=[1, 2, 3],
    ring_settings=[1, 1, 1],
    reflector="B",
    plugboard=[],
    message_key="ADA",
)
assert enigma.encode("A" * 50) == "DBBZZLXLCYZXIFGWFDZEEQIBMGFJBWZFCKPFMGBXQCIVIBBRNC"
# Message test (Operation Barbarossa, 1941)

enigma = Enigma(
    rotor_order=[2, 4, 5],
    ring_settings=[2, 21, 12],
    reflector="B",
    plugboard=["AV", "BS", "CG", "DL", "FU", "HZ", "IN", "KM", "OW", "RX"],
    message_key="BLA",
)

plaintext = "AUFKLXABTEILUNGXVONXKURTINOWAXKURTINOWAXNORDWESTLXSEBEZXSEBEZXUAFFLIEGERSTRASZERIQTUNGXDUBROWKIXDUBROWKIXOPOTSCHKAXOPOTSCHKAXUMXEINSAQTDREINULLXUHRANGETRETENXANGRIFFXINFXRGTX"
ciphertext = "EDPUDNRGYSZRCXNUYTPOMRMBOFKTBZREZKMLXLVEFGUEYSIOZVEQMIKUBPMMYLKLTTDEISMDICAGYKUACTCDOMOHWXMUUIAUBSTSLRNBZSZWNRFXWFYSSXJZVIJHIDISHPRKLKAYUPADTXQSPINQMATLPIFSVKDASCTACDPBOPVHJK"

assert enigma.encode(plaintext) == ciphertext

Understanding the British Bombe

During World War II, Alan Turing and Dillwyn Knox, faced the daunting task of determining the settings of the Enigma machine used in enciphering messages. They had three potential methods of attack: ciphertext-only analysis, discriminant attack, and probable-phrase attack. The ciphertext-only analysis, requiring lengthy ciphertext, was impractical for Enigma’s typically short messages. The discriminant attack, though successful for the Poles in discovering the rotor wirings, was deemed too risky by Turing and Knox due to the possibility of procedural changes by the Germans, which did occur in May 1940. Based on Turing’s insight, the most promising method was the probable-phrase attack.

The British Bombe was Alan Turing’s successful attempt at mounting a probable-phrase attack. This involved exploiting the relationship between a known or guessed plaintext portion (crib) and the corresponding ciphertext, aiming to eliminate many potential Enigma setups. A vital enhancement to this approach was Gordon Welchman’s introduction of the diagonal board, which effectively narrowed down many of the possible combinations. The challenge was to find sufficient cribs, but only one was needed per network per day, as deciphering a single message could reveal the day’s settings for all messages in that network. The Bombe was focused solely on discovering the daily key settings, including the rotor order, rotor positions, and plugboard settings, crucial for decrypting Enigma-encoded messages.

import networkx as nx


class Bombe:
    """
    Simulates a Bombe machine to execute a probable-phrase attack using an aligned crib
    and ciphertext.
    """

    def __init__(self, crib, ciphertext, rotor_order):
        """
        Initializes the Bombe machine.
        :param crib: Known plaintext corresponding to a part of the ciphertext.
        :param ciphertext: Encrypted text corresponding to the crib.
        :param rotor_order: Order of rotors in the Enigma machine being simulated.
        """
        assert len(crib) == len(ciphertext)  # must be of equal length
        assert len(crib) <= 26  # if more than 26, guaranteed to be a middle rotor step
        assert all([c1 != c2 for c1, c2 in zip(crib, ciphertext)])  # must not crash

        self.crib = crib
        self.ciphertext = ciphertext
        self.rotor_order = rotor_order
        self.enigmas = [
            Enigma(rotor_order=self.rotor_order, message_key=f"AA{c}")
            for c in ALPHABET[: len(crib)]
        ]
        self.menu = self.create_menu(crib, ciphertext)

    def create_menu(self, crib, ciphertext):
        """
        Generate the menu representing the relationship between the crib and ciphertext.
        """
        menu = nx.Graph()
        menu.add_nodes_from(set(crib + ciphertext))
        menu.add_edges_from((*c, {"p": i}) for i, c in enumerate(zip(crib, ciphertext)))

        print(f"Loops: {len(nx.cycle_basis(menu))}")
        print(f"Letters: {menu.number_of_nodes()}")
        print(f"Links: {menu.number_of_edges()}")

        pos = nx.spring_layout(menu)
        nx.draw(menu, pos, font_color="white", with_labels=True)
        edge_labels = {(n1, n2): p + 1 for n1, n2, p in menu.edges(data="p")}
        nx.draw_networkx_edge_labels(menu, pos=pos, edge_labels=edge_labels, alpha=0.5)

        return menu

    def apply_voltage(self, wire, graph):
        """
        Apply voltage to a specified wire in the graph and propagate the "live" state.
        """
        nx.set_node_attributes(graph, False, "live")
        nx.set_edge_attributes(graph, False, "live")
        subgraph = graph.subgraph(nx.node_connected_component(graph, wire))
        nx.set_node_attributes(subgraph, True, "live")
        nx.set_edge_attributes(subgraph, True, "live")

    def check_stop(self, plugboard, self_steckered):
        """
        Check if a stop condition is valid based on the plugboard configuration.
        """
        letters = "".join(plugboard + self_steckered)
        if len(set(letters)) != len(letters):  # ensure no double-steckering
            return False
        
        if len(plugboard) < 10:
            return True  # could be correct, but need to brute force the remaining plugs
        elif len(plugboard) == 10:
            return True  # if using all 10 plugs, it's correct for sure
        else:
            return False  # only 10 plugs can be used

    def run(self):
        """
        Run the Bombe simulation to find possible message key and plugboard settings.
        """
        possible_solutions = []
        for _ in range(26 * 26 * 26):
            message_key = self.enigmas[0].get_message_key()

            # Rotate Enigmas
            for enigma in self.enigmas:
                enigma.rotate_rotors(double_stepping=False)

            # Skip iterations where the middle rotor steps
            if self.enigmas[0].rotors[1].position - self.enigmas[-1].rotors[1].position:
                continue

            # Build Welchman's diagonal board wiring
            diagonal_board_wiring = {
                ((c1, c2), (c2, c1)) for c1 in ALPHABET for c2 in ALPHABET if c1 < c2
            }

            # Build scrambler wiring
            scrambler_wiring = set()
            for c1, c2, position in self.menu.edges(data="p"):
                enigma = self.enigmas[position]
                for w1, w2 in zip(ALPHABET, enigma.encode(ALPHABET, rotate=False)):
                    scrambler_wiring.add(((c1, w1), (c2, w2)))

            # Build wiring graph including diagonal board and scrambler wiring
            G = nx.Graph()
            G.add_nodes_from([(c, w) for c in ALPHABET for w in ALPHABET], live=False)
            G.add_edges_from(diagonal_board_wiring | scrambler_wiring, live=False)

            DB = G.edge_subgraph(diagonal_board_wiring)

            # Affix test register to the most connected cable
            most_connected_cable = max(self.menu.degree(), key=lambda x: x[1])[0]
            test_register = G.subgraph({(most_connected_cable, c) for c in ALPHABET})

            # Iterate over potential dead wires
            candidate_wires = {
                node for node, live in test_register.nodes(data="live") if not live
            }
            while candidate_wires:
                wire = candidate_wires.pop()
                self.apply_voltage(wire, G)
                live_wires = {
                    node for node, live in test_register.nodes(data="live") if live
                }
                if len(live_wires) == 1:
                    plugboard = [
                        "".join(node1)
                        for node1, node2, live in DB.edges(data="live")
                        if live
                    ]
                    self_steckered = [
                        c for (c, w), live in G.nodes(data="live") if live and c == w
                    ]
                    if self.check_stop(plugboard, self_steckered):
                        possible_solutions.append(
                            (self.rotor_order, message_key, plugboard, self_steckered)
                        )
                else:
                    candidate_wires -= (
                        live_wires  # rule out the 1+ live wires that have lit up
                    )

        return possible_solutions

The Bombe’s operation involved the synchronized rotation of drums, each simulating an Enigma rotor, to test different configurations. Aligning the crib and ciphertext generated a “menu” that was used to plug up the Bombe in preparation for a run. The menu described, in graph format, which letters were related at which positions. Then, scramblers, are placed at each position, bridging the cables corresponding to the letters, with the initial position set to how far along it is. Then the rotors would tick in synchrony.

bombe = Bombe("WETTERVORHER", "RWIVTYRESXBF", [1, 5, 3])
Loops: 1
Letters: 13
Links: 12
Menu representing the relationship between the crib and ciphertext
Menu representing the relationship between the crib and ciphertext

If the machine detected a “stop”, a condition where the crib-ciphertext transformation matched a potential Enigma setting, it paused for verification. This was determined by the state of wires in a test register – either one live wire (indicating a true hypothesis) or one dead wire among 25 live ones (indicating a false hypothesis). The Bombe then recorded the rotor positions for further examination. Stops were verified by inputting the ciphertext into a checking machine set with the Bombe’s findings. If the output resembled German text, it indicated a correct decryption, signaling a successful breaking of the Enigma code for that day. The Bombe, thus, was a crucial tool in deciphering Axis communications, aiding the Allied forces significantly during the war.

candidates = bombe.run()

Nicely formatting the candidates:

candidates = [
    ((1, 2, 4), 'MZF', ['AR', 'CI', 'DX', 'EM', 'FQ', 'HO', 'KS', 'NV', 'PW', 'UY'], ['B', 'T']),
    ((1, 3, 2), 'VTY', ['BL', 'DR', 'EM', 'IU', 'JO', 'NW', 'QY', 'TV'], ['F', 'S']),
    ((1, 3, 4), 'XME', ['AR', 'BK', 'DI', 'EM', 'JY', 'LS', 'OZ', 'PW', 'UV'], ['F', 'T']),
    ((1, 3, 4), 'YHD', ['AR', 'BG', 'CW', 'DF', 'EN', 'IO', 'LY', 'MS', 'QT', 'UV'], []),
    ((1, 3, 5), 'JHL', ['AS', 'BE', 'CV', 'IN', 'KO', 'MW', 'QT'], ['F', 'R', 'Y']),
    ((1, 3, 5), 'LGC', ['AW', 'BJ', 'CO', 'DT', 'EL', 'FH', 'IK', 'QV', 'UY', 'XZ'], ['R', 'S']),
    ((1, 4, 2), 'IEB', ['BQ', 'EK', 'FU', 'IW', 'JO', 'LT', 'MY', 'RZ'], ['S', 'V']),
    ((1, 4, 3), 'JGB', ['AS', 'BC', 'EJ', 'FM', 'GO', 'IU', 'LT', 'PW', 'RY'], ['V']),
    ((1, 4, 5), 'QVL', ['BX', 'EG', 'FS', 'HO', 'IQ', 'JR', 'KV', 'NT', 'WZ'], ['Y']),
    ((1, 5, 3), 'BBE', ['AW', 'CY', 'FH', 'IQ', 'JS', 'LX', 'MT', 'OP', 'RU', 'VZ'], ['B', 'E']),
    ((1, 5, 4), 'UHH', ['AR', 'BP', 'EJ', 'FM', 'GW', 'KO', 'NT', 'QY', 'SZ'], ['I', 'V']),
    ((2, 1, 3), 'NFG', ['BF', 'HW', 'IL', 'JT', 'KO', 'MY', 'NS', 'PV', 'QX', 'RU'], ['E']),
    ((2, 3, 4), 'UUD', ['AR', 'BH', 'EZ', 'FQ', 'GX', 'JO', 'KT', 'LV', 'NS', 'UW'], ['I', 'Y']),
    ((2, 3, 4), 'WLF', ['BZ', 'CI', 'FM', 'KT', 'LS', 'OR', 'QV', 'UY'], ['E', 'W']),
    ((2, 3, 5), 'NBF', ['AE', 'BH', 'CF', 'DV', 'IX', 'KT', 'MS', 'NW', 'PY', 'QR'], ['O']),
    ((2, 3, 5), 'PJK', ['AI', 'BP', 'DY', 'EF', 'JW', 'KS', 'LT', 'NV', 'OU', 'RX'], ['H']),
    ((2, 4, 1), 'ZQR', ['AV', 'BP', 'DT', 'EK', 'FG', 'IN', 'LW', 'MO', 'SU'], ['R', 'Y']),
    ((2, 4, 3), 'FGW', ['AS', 'BF', 'CV', 'EJ', 'HI', 'OQ', 'PT', 'RU', 'YZ'], ['W', 'X']),
    ((2, 5, 1), 'RSX', ['AB', 'DY', 'EH', 'FW', 'GT', 'IN', 'JS', 'PR'], ['O', 'V', 'X']),
    ((2, 5, 3), 'YVB', ['AS', 'CH', 'EU', 'FO', 'GT', 'MR', 'NV', 'WX', 'YZ'], ['B', 'I']),
    ((3, 1, 2), 'IRA', ['AT', 'BM', 'EU', 'GS', 'IP', 'NO', 'YZ'], ['F', 'R', 'V', 'W']),
    ((3, 1, 4), 'IRB', ['AV', 'EK', 'FG', 'JO', 'LY', 'NR', 'QT', 'SZ', 'WX'], ['B', 'H', 'I']),
    ((3, 1, 5), 'BWF', ['AT', 'CY', 'DR', 'EZ', 'FP', 'GO', 'IU', 'JW', 'LV'], ['B', 'S']),
    ((3, 2, 1), 'SHB', ['AF', 'BC', 'EZ', 'IR', 'JT', 'KV', 'LW', 'MS', 'OY'], []),
    ((3, 2, 1), 'WVX', ['AS', 'CW', 'DI', 'EG', 'FJ', 'KV', 'QY', 'RZ'], ['B', 'O', 'T']),
    ((3, 2, 5), 'SGE', ['AB', 'CT', 'ER', 'FK', 'GO', 'HI', 'LV', 'PS', 'QX', 'WZ'], ['Y']),
    ((3, 4, 5), 'UHN', ['CE', 'DO', 'FU', 'GY', 'IL', 'NR', 'QV', 'SW', 'TZ'], ['B']),
    ((3, 5, 4), 'SVI', ['AI', 'BZ', 'CT', 'DV', 'EU', 'GO', 'KR', 'PS', 'QW'], ['F', 'Y']),
    ((3, 5, 4), 'XLH', ['EX', 'FI', 'GW', 'HL', 'JR', 'NT', 'OU', 'PV', 'QS', 'YZ'], ['B']),
    ((4, 1, 3), 'JLI', ['DW', 'EU', 'GO', 'HV', 'IZ', 'LR', 'MY', 'NT', 'PX'], ['B', 'F', 'S']),
    ((4, 1, 3), 'QTA', ['AY', 'CT', 'DV', 'EU', 'FG', 'IL', 'JS', 'NW', 'OZ', 'PR'], ['B']),
    ((4, 2, 1), 'SQC', ['AF', 'CO', 'DW', 'EM', 'IN', 'KY', 'PV', 'QT', 'RZ', 'SU'], ['B']),
    ((4, 2, 1), 'SXZ', ['AI', 'BQ', 'CT', 'EU', 'FP', 'GV', 'JY', 'OZ', 'RW'], ['S']),
    ((4, 2, 5), 'THL', ['BW', 'DF', 'EP', 'GY', 'IZ', 'JS', 'KV', 'OQ'], ['R', 'T']),
    ((4, 3, 1), 'PCA', ['BM', 'CF', 'DY', 'EQ', 'IJ', 'LR', 'OS', 'VW'], ['T']),
    ((4, 3, 1), 'VMA', ['AW', 'BG', 'CV', 'EP', 'FY', 'IM', 'NT', 'QR'], ['O', 'S']),
    ((4, 3, 5), 'OCJ', ['AV', 'CF', 'EL', 'GY', 'HJ', 'IN', 'KO', 'PT', 'QW', 'SX'], ['B', 'R']),
    ((4, 5, 3), 'OIG', ['AE', 'BZ', 'CR', 'GY', 'IL', 'JO', 'KV', 'QS', 'TW'], ['F']),
    ((5, 1, 2), 'CPA', ['AB', 'DT', 'ES', 'FW', 'GI', 'JR', 'OP', 'QY', 'UV'], []),
    ((5, 1, 3), 'FKV', ['CS', 'EP', 'FQ', 'GW', 'IZ', 'KR', 'LY', 'UV'], ['B', 'O', 'T']),
    ((5, 2, 1), 'OGA', ['BJ', 'DV', 'EM', 'FK', 'GR', 'IU', 'LS', 'OW', 'PY', 'TZ'], []),
    ((5, 2, 1), 'TKA', ['AS', 'BR', 'DT', 'EU', 'FQ', 'GO', 'HI', 'JW', 'PV', 'YZ'], ['X']),
    ((5, 3, 2), 'CBX', ['AB', 'CY', 'EM', 'FG', 'IW', 'OQ', 'RU', 'SZ'], ['T', 'V']),
    ((5, 4, 2), 'ZFW', ['BL', 'CO', 'EM', 'FI', 'GW', 'JR', 'KT', 'SU', 'VZ'], ['Y']),
    ((5, 4, 3), 'XDE', ['BJ', 'CI', 'DF', 'EG', 'LO', 'NS', 'RT', 'VY', 'WZ'], []),
    ((5, 4, 3), 'ZRE', ['AX', 'BH', 'CV', 'EM', 'FQ', 'IJ', 'KO', 'LR', 'TU', 'YZ'], ['S', 'W']),
]

For each candidate, we have the rotor order, message key, potential plugboard settings and the self-steckered pairs. Looping through them, we can apply the settings to a checking machine and run the ciphertext through it. In cases where the plugboard has less than 10 plugs, the remaining plug positions need to be determined by brute force.

for rotor_order, message_key, plugboard, self_steckered in candidates:
    enigma = Enigma(
        rotor_order=rotor_order,
        plugboard=plugboard,
        message_key=message_key,
    )
    print(len(plugboard), enigma.encode("RWIVTYRESXBF", rotate=True))
10 WETTERVORHVH
8  WETTEQUGPQIB
9  WETTERVORRMC
10 WETTERVORWQY
7  WETTERVORKER
10 WETTERVORHER
8  WETTEMDLTSLE
9  WETTERVORBER
9  WETTERVORHER
10 WETTERVORHER
9  WETTERVORVMS
10 WETTERVORHER
10 WETTERVGYJTS
8  WETTERVORUUM
10 WETTERVORHER
10 WETTERVORHER
9  WETTERVORPER
9  WETTERVORHER
8  WETTERVORHER
9  WETTERVORHER
7  WETTEPAHHAYY
9  WETTERVORHHW
9  WETTERVORKER
9  WETTERVORTER
8  WETTERVORUER
10 WETTERVORHER
9  WETTERVORPER
9  WETTERVORGEM
10 WETTERVORHGD
9  WETTERVORHER
10 WETTERVORVER
10 WETTERVORHER
9  WETTERVORSER
8  WETTERVORHER
8  WETTERVORPER
8  WETTERVORJER
10 WETTERVORHER
9  WETTERVORVER
9  WETTEGYNMGKR
8  WETTERVORIER
10 WETTERVORTER
10 WETTERVORHER
8  WETTENICDOEZ
9  WETTELKKEWHL
9  WETTERVORKER
10 WETTERVORHER

Finally, the settings need to be applied on the entire ciphertext to see if it can decrypt the entire message.

References