Chapter 10: Enigma – Design and Electromechanical Operation#

10.1 Historical Context#

The Enigma machine stands as perhaps the most famous cipher device in history – not merely for its ingenious design, but for the extraordinary intellectual effort required to break it. Its story intertwines engineering, mathematics, espionage, and the fate of nations.

The Invention#

In 1918, the German electrical engineer Arthur Scherbius (1878–1929) filed a patent for an electromechanical cipher machine that used rotating wired wheels (“rotors”) to implement a polyalphabetic substitution of staggering complexity. Scherbius founded the Chiffriermaschinen Aktiengesellschaft (Cipher Machines Corporation) and initially marketed the device to commercial customers – banks, corporations, and diplomatic services. This Commercial Enigma was publicly available and its wiring was not secret.

Military Adoption#

Beginning in 1926, the German Reichswehr (and later the Wehrmacht) adopted a modified version of the Enigma for military communications. The Military Enigma differed from the commercial model in several critical ways:

  • A plugboard (Steckerbrett) was added at the front, providing an additional permutation layer that vastly expanded the key space.

  • The rotor wirings were changed and classified as secret.

  • Standardized operating procedures dictated daily key settings distributed via printed key sheets (Kenngruppenbuch).

Progressive Complexity#

The German military progressively increased the Enigma’s complexity over the course of the war:

Year

Change

Effect

1930

Plugboard added to Wehrmacht Enigma

Key space multiplied by \(\approx 1.5 \times 10^{14}\)

1938

Rotor set expanded from 3 to 5 (choose 3)

Rotor order choices increased from \(6\) to \(60\)

1939

Indicator procedure changed

Broke the method used by Polish cryptanalysts

1942

Naval Enigma M4: 4th rotor added (thin reflector)

Key space expanded further; caused the “blackout” at Bletchley Park

The Codebreakers#

The first systematic cryptanalysis of the military Enigma was achieved not at Bletchley Park, but in Warsaw. In 1932, the Polish mathematician Marian Rejewski (1905–1980), working at the Biuro Szyfrów (Cipher Bureau), exploited the repeated indicator procedure and the theory of permutations to deduce the Enigma’s internal wiring – a feat of pure mathematical brilliance. His colleagues Jerzy Różycki and Henryk Zygalski contributed further techniques. In July 1939, just weeks before the German invasion of Poland, the Poles shared their methods with French and British intelligence.

At Bletchley Park, Alan Turing, Gordon Welchman, and their colleagues built upon the Polish work to develop the electromechanical Bombe – a device that could test Enigma key settings at high speed using known-plaintext cribs. The breaking of Enigma is estimated to have shortened World War II by two to three years.

“The Enigma enciphered every letter differently, depending on the setting of the machine. There were billions and billions of possible settings, and it was this astronomically large number that gave the Germans confidence in the Enigma’s security.”

Marian Rejewski, interview (1979)

Tip

Why study the Enigma? The Enigma embodies many fundamental concepts in modern cryptanalysis: permutation groups, involutions, key-space enumeration, known-plaintext attacks, and the interplay between mathematical structure and practical vulnerability. Understanding its design and its weaknesses provides deep insight into why modern ciphers are designed the way they are.

10.2 Formal Definitions#

We now formalize the mathematical structure of the Enigma machine. Throughout this section, we work in \(\mathbb{Z}_{26} = \{0, 1, \ldots, 25\}\), identified with the letters \(\texttt{A} = 0, \texttt{B} = 1, \ldots, \texttt{Z} = 25\).

Definition 10.1 (Rotor)

A rotor is a wired permutation \(\sigma : \mathbb{Z}_{26} \to \mathbb{Z}_{26}\) that can be rotated. When the rotor is at position \(p \in \mathbb{Z}_{26}\) with ring setting \(r \in \mathbb{Z}_{26}\), the effective forward permutation is:

\[ \sigma_p(x) = \sigma\bigl(x + (p - r)\bigr) - (p - r) \pmod{26}\]

and the backward (inverse) permutation is:

\[ \sigma_p^{-1}(x) = \sigma^{-1}\bigl(x + (p - r)\bigr) - (p - r) \pmod{26}\]

Each rotor has a notch position \(n \in \mathbb{Z}_{26}\): when the rotor reaches position \(n\), it triggers the advancement of the next rotor to its left.

Definition 10.2 (Reflector)

A reflector is a fixed-point-free involution \(\rho : \mathbb{Z}_{26} \to \mathbb{Z}_{26}\):

  1. \(\rho^2 = \mathrm{id}\) (involution: \(\rho(\rho(x)) = x\) for all \(x\)),

  2. \(\rho(x) \neq x\) for all \(x \in \mathbb{Z}_{26}\) (no fixed points).

The reflector consists of exactly 13 transpositions (2-cycles). It ensures that the signal returns through the rotors along a different path, which is what makes the Enigma self-reciprocal.

Definition 10.3 (Plugboard / Steckerbrett)

The plugboard is an involution \(P : \mathbb{Z}_{26} \to \mathbb{Z}_{26}\) composed of at most 13 transpositions (typically exactly 10 in wartime use). Letters not connected by a plug cable are mapped to themselves.

If the plugboard swaps \(s\) pairs, it can be written as the product of \(s\) disjoint transpositions, with \(26 - 2s\) fixed points.

Definition 10.4 (Enigma Permutation)

For an Enigma machine with three rotors \(R_1, R_2, R_3\) (right to left), reflector \(U\), and plugboard \(P\), the encryption of a single character is the permutation:

\[ E(x) = P \circ R_1^{-1} \circ R_2^{-1} \circ R_3^{-1} \circ U \circ R_3 \circ R_2 \circ R_1 \circ P(x)\]

where each \(R_i\) denotes the rotor permutation at its current position. The signal path is:

\[ \texttt{Keyboard} \xrightarrow{P} \xrightarrow{R_1} \xrightarrow{R_2} \xrightarrow{R_3} \xrightarrow{U} \xrightarrow{R_3^{-1}} \xrightarrow{R_2^{-1}} \xrightarrow{R_1^{-1}} \xrightarrow{P} \texttt{Lampboard}\]

Crucially, the rotors step before each character is encrypted, so the permutation \(E\) changes with every keypress.

Definition 10.5 (Stepping Mechanism)

The Enigma’s rotors advance like an odometer, but with an important anomaly:

  1. The right rotor (\(R_1\)) steps with every keypress.

  2. The middle rotor (\(R_2\)) steps when \(R_1\) passes through its notch position.

  3. The left rotor (\(R_3\)) steps when \(R_2\) passes through its notch position.

  4. Double-stepping anomaly: When \(R_2\) is at its own notch position, it steps again on the next keypress (even if \(R_1\) has not reached its notch). This means \(R_2\) steps on two consecutive keypresses.

This mechanical anomaly arises because the same pawl that advances \(R_2\) (triggered by \(R_1\)’s notch) also engages \(R_2\)’s own notch ring, causing \(R_3\) to advance and \(R_2\) to step again.

Definition 10.6 (Self-Reciprocal Property)

The Enigma permutation \(E\) is an involution: \(E(E(x)) = x\) for every \(x \in \mathbb{Z}_{26}\).

This means that encryption and decryption are the same operation: to decrypt a ciphertext, one simply types it into an Enigma machine with the same settings and reads off the plaintext. This was a deliberate design choice for operational convenience.

Danger

Critical weaknesses exploited by cryptanalysts. Two properties of the Enigma – both consequences of the reflector – proved to be devastating vulnerabilities:

  1. Self-reciprocal property (\(E = E^{-1}\)): This constraint reduces the space of possible permutations from \(26!\) to the much smaller set of involutions, making mathematical analysis tractable.

  2. No letter encrypts to itself (\(E(x) \neq x\) for all \(x\)): This “no-fixed-point” property allowed codebreakers to eliminate impossible crib positions. If a proposed plaintext letter coincided with the corresponding ciphertext letter at any position, that crib placement could be immediately ruled out. This dramatically reduced the search space for the Bombe.

10.3 Implementation#

We now implement a fully functional Enigma I simulator using the historical rotor wirings employed by the German Wehrmacht. The implementation consists of four classes: EnigmaRotor, EnigmaReflector, Plugboard, and EnigmaMachine.

Historical Rotor Wirings (Enigma I)#

Component

Wiring (ABCDEFGHIJKLMNOPQRSTUVWXYZ \(\to\))

Notch

Rotor I

EKMFLGDQVZNTOWYHXUSPAIBRCJ

Q

Rotor II

AJDKSIRUXBLHWTMCQGZNPYFVOE

E

Rotor III

BDFHJLCPRTXVZNYEIWGAKMUSQO

V

Rotor IV

ESOVPZJAYQUIRHXLNFTGKDCMWB

J

Rotor V

VZBRGITYUPSDNHLXAWMJQOFECK

Z

Reflector B (UKW-B)

YRUHQSLDPXNGOKMIEBFZCWVJAT

Reflector C (UKW-C)

FVPJIAOYEDRZXWGCTKUQSBNMHL

import numpy as np

# Enigma Machine Implementation
# ==============================

ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'


class EnigmaRotor:
    """
    A single Enigma rotor with wiring, notch, ring setting, and position.

    Parameters
    ----------
    wiring : str
        26-character string defining the permutation.
    notch : str
        Single character giving the notch position.
    ring_setting : int
        Ring setting (Ringstellung), 0-25.
    position : int
        Initial rotor position (Grundstellung), 0-25.
    name : str
        Human-readable name for the rotor.
    """

    def __init__(self, wiring, notch, ring_setting=0, position=0, name=""):
        self.name = name
        self.wiring = wiring
        self.notch = ord(notch) - ord('A')
        self.ring_setting = ring_setting
        self.position = position

        # Build forward and inverse permutation arrays
        self.forward_map = np.array([ord(c) - ord('A') for c in wiring], dtype=int)
        self.inverse_map = np.zeros(26, dtype=int)
        for i in range(26):
            self.inverse_map[self.forward_map[i]] = i

    def forward(self, x):
        """Signal passing through the rotor left-to-right (keyboard toward reflector)."""
        shift = self.position - self.ring_setting
        return (self.forward_map[(x + shift) % 26] - shift) % 26

    def backward(self, x):
        """Signal passing through the rotor right-to-left (reflector toward lampboard)."""
        shift = self.position - self.ring_setting
        return (self.inverse_map[(x + shift) % 26] - shift) % 26

    def step(self):
        """Advance the rotor by one position."""
        self.position = (self.position + 1) % 26

    def at_notch(self):
        """Check whether the rotor is at its notch position.

        The ring setting shifts the relationship between the internal
        wiring position and the visible letter on the rotor.  The notch
        is defined relative to the wiring, so the visible position that
        triggers turnover is offset by the ring setting.
        """
        return self.position == (self.notch + self.ring_setting) % 26

    def __repr__(self):
        pos_char = chr(self.position + ord('A'))
        return f"Rotor({self.name}, pos={pos_char}, ring={self.ring_setting})"


class EnigmaReflector:
    """
    Enigma reflector (Umkehrwalze) -- a fixed-point-free involution.

    Parameters
    ----------
    wiring : str
        26-character string defining the reflector permutation.
    name : str
        Human-readable name.
    """

    def __init__(self, wiring, name=""):
        self.name = name
        self.wiring = wiring
        self.map = np.array([ord(c) - ord('A') for c in wiring], dtype=int)

        # Verify involution and fixed-point-free properties
        for i in range(26):
            assert self.map[self.map[i]] == i, f"Not an involution at position {i}"
            assert self.map[i] != i, f"Fixed point at position {i}"

    def reflect(self, x):
        """Apply the reflector permutation."""
        return int(self.map[x])

    def __repr__(self):
        return f"Reflector({self.name})"


class Plugboard:
    """
    Enigma plugboard (Steckerbrett) -- an involution with at most 13 transpositions.

    Parameters
    ----------
    pairs : list of tuples
        Each tuple is a pair of characters to swap, e.g., [('A','B'), ('C','D')].
    """

    def __init__(self, pairs=None):
        self.map = np.arange(26, dtype=int)  # identity by default
        self.pairs = pairs if pairs else []

        if len(self.pairs) > 13:
            raise ValueError("Plugboard cannot have more than 13 pairs.")

        used = set()
        for a, b in self.pairs:
            ia, ib = ord(a) - ord('A'), ord(b) - ord('A')
            if ia in used or ib in used:
                raise ValueError(f"Letter used more than once in plugboard: {a} or {b}")
            used.add(ia)
            used.add(ib)
            self.map[ia] = ib
            self.map[ib] = ia

    def swap(self, x):
        """Apply the plugboard permutation."""
        return int(self.map[x])

    def __repr__(self):
        pair_str = " ".join(f"{a}{b}" for a, b in self.pairs)
        return f"Plugboard({pair_str})"


class EnigmaMachine:
    """
    Complete Enigma I simulator with three rotors, reflector, and plugboard.
    Implements the full signal path and the double-stepping mechanism.

    Parameters
    ----------
    rotors : list of EnigmaRotor
        Three rotors, ordered [right, middle, left].
    reflector : EnigmaReflector
        The reflector.
    plugboard : Plugboard
        The plugboard.
    """

    def __init__(self, rotors, reflector, plugboard=None):
        self.rotors = rotors  # [right, middle, left]
        self.reflector = reflector
        self.plugboard = plugboard if plugboard else Plugboard()
        self._initial_positions = [r.position for r in self.rotors]

    def step_rotors(self):
        """
        Advance the rotors according to the Enigma stepping mechanism,
        including the double-stepping anomaly.

        Stepping occurs BEFORE encryption of each character:
        1. If the middle rotor is at its notch, both middle and left rotors step.
        2. If the right rotor is at its notch, the middle rotor steps.
        3. The right rotor always steps.
        """
        # Check for double-stepping: middle rotor at its notch
        if self.rotors[1].at_notch():
            self.rotors[1].step()
            self.rotors[2].step()
        # Normal middle rotor stepping: right rotor at its notch
        elif self.rotors[0].at_notch():
            self.rotors[1].step()

        # Right rotor always steps
        self.rotors[0].step()

    def encrypt_char(self, char):
        """Encrypt a single character through the full Enigma signal path."""
        self.step_rotors()

        x = ord(char) - ord('A')

        # Plugboard (forward)
        x = self.plugboard.swap(x)

        # Rotors forward: right -> middle -> left
        for rotor in self.rotors:
            x = rotor.forward(x)

        # Reflector
        x = self.reflector.reflect(x)

        # Rotors backward: left -> middle -> right
        for rotor in reversed(self.rotors):
            x = rotor.backward(x)

        # Plugboard (backward)
        x = self.plugboard.swap(x)

        return chr(x + ord('A'))

    def encrypt(self, plaintext):
        """Encrypt a string of uppercase letters."""
        return "".join(self.encrypt_char(c) for c in plaintext.upper() if c.isalpha())

    def reset(self, positions=None):
        """Reset rotor positions to initial or specified positions."""
        if positions is None:
            positions = self._initial_positions
        for rotor, pos in zip(self.rotors, positions):
            rotor.position = pos
        self._initial_positions = [r.position for r in self.rotors]

    def get_positions(self):
        """Return current rotor positions as a string (e.g., 'AAA')."""
        return ''.join(chr(r.position + ord('A')) for r in self.rotors)

    def __repr__(self):
        rotors_str = ", ".join(str(r) for r in self.rotors)
        return f"EnigmaMachine([{rotors_str}], {self.reflector}, {self.plugboard})"


# Historical Rotor Definitions
# =============================

ROTOR_WIRINGS = {
    'I':   ('EKMFLGDQVZNTOWYHXUSPAIBRCJ', 'Q'),
    'II':  ('AJDKSIRUXBLHWTMCQGZNPYFVOE', 'E'),
    'III': ('BDFHJLCPRTXVZNYEIWGAKMUSQO', 'V'),
    'IV':  ('ESOVPZJAYQUIRHXLNFTGKDCMWB', 'J'),
    'V':   ('VZBRGITYUPSDNHLXAWMJQOFECK', 'Z'),
}

REFLECTOR_WIRINGS = {
    'B': 'YRUHQSLDPXNGOKMIEBFZCWVJAT',
    'C': 'FVPJIAOYEDRZXWGCTKUQSBNMHL',
}


def make_rotor(name, ring_setting=0, position=0):
    """Create a rotor from the historical wiring tables."""
    wiring, notch = ROTOR_WIRINGS[name]
    return EnigmaRotor(wiring, notch, ring_setting, position, name=f'Rotor {name}')


def make_reflector(name):
    """Create a reflector from the historical wiring tables."""
    return EnigmaReflector(REFLECTOR_WIRINGS[name], name=f'UKW-{name}')


def make_enigma(rotor_names=('I', 'II', 'III'), reflector_name='B',
                ring_settings=(0, 0, 0), positions=(0, 0, 0),
                plugboard_pairs=None):
    """
    Convenience function to create a fully configured Enigma machine.

    Parameters
    ----------
    rotor_names : tuple of str
        Names of three rotors (right, middle, left).
    reflector_name : str
        Reflector name ('B' or 'C').
    ring_settings : tuple of int
        Ring settings for (right, middle, left).
    positions : tuple of int
        Initial positions for (right, middle, left).
    plugboard_pairs : list of tuples, optional
        Plugboard connections.
    """
    rotors = [make_rotor(n, r, p)
              for n, r, p in zip(rotor_names, ring_settings, positions)]
    reflector = make_reflector(reflector_name)
    plugboard = Plugboard(plugboard_pairs)
    return EnigmaMachine(rotors, reflector, plugboard)


# Quick verification
enigma = make_enigma()
print('Enigma machine created successfully:')
print(enigma)
print(f'Initial positions: {enigma.get_positions()}')
Enigma machine created successfully:
EnigmaMachine([Rotor(Rotor I, pos=A, ring=0), Rotor(Rotor II, pos=A, ring=0), Rotor(Rotor III, pos=A, ring=0)], Reflector(UKW-B), Plugboard())
Initial positions: AAA

Experiment 1: Encrypt and Decrypt#

We demonstrate the fundamental operation of the Enigma: encrypting a plaintext message and then decrypting the ciphertext by running it through the machine with identical settings.

import numpy as np

# Create an Enigma with specific settings
# Rotors I, II, III; Reflector B; positions M-C-K
# Plugboard: AR GN IX BQ HW FT OU LZ CY PS
enigma_enc = make_enigma(
    rotor_names=('I', 'II', 'III'),
    reflector_name='B',
    positions=(12, 2, 10),  # M, C, K
    plugboard_pairs=[('A','R'), ('G','N'), ('I','X'), ('B','Q'),
                     ('H','W'), ('F','T'), ('O','U'), ('L','Z'),
                     ('C','Y'), ('P','S')]
)

plaintext = 'HELLOWORLD'
ciphertext = enigma_enc.encrypt(plaintext)
print(f'Plaintext:  {plaintext}')
print(f'Ciphertext: {ciphertext}')

# Now decrypt: create an identical machine and encrypt the ciphertext
enigma_dec = make_enigma(
    rotor_names=('I', 'II', 'III'),
    reflector_name='B',
    positions=(12, 2, 10),
    plugboard_pairs=[('A','R'), ('G','N'), ('I','X'), ('B','Q'),
                     ('H','W'), ('F','T'), ('O','U'), ('L','Z'),
                     ('C','Y'), ('P','S')]
)

recovered = enigma_dec.encrypt(ciphertext)
print(f'Decrypted:  {recovered}')
print(f'\nDecryption successful: {recovered == plaintext}')
Plaintext:  HELLOWORLD
Ciphertext: OLBFZTRQUI
Decrypted:  HELLOWORLD

Decryption successful: True

Experiment 2: Self-Reciprocal Verification#

The Enigma is self-reciprocal: \(E(E(x)) = x\). We verify this by encrypting a longer message and then encrypting the ciphertext – the original plaintext should be recovered exactly.

import numpy as np

message = 'THEQUICKBROWNFOXJUMPSOVERTHELAZYDOG'

# Encrypt
enigma_a = make_enigma(positions=(7, 4, 11))  # H, E, L
ct = enigma_a.encrypt(message)

# 'Decrypt' by encrypting again with same initial settings
enigma_b = make_enigma(positions=(7, 4, 11))
recovered = enigma_b.encrypt(ct)

print(f'Original:   {message}')
print(f'Encrypted:  {ct}')
print(f'Re-encrypt: {recovered}')
print(f'\nSelf-reciprocal verified: {recovered == message}')

# Character-by-character verification
print('\nCharacter-by-character verification:')
print(f'{"Position":<10} {"Plain":<8} {"Cipher":<8} {"Recovered":<10}')
print('-' * 36)
for i in range(len(message)):
    print(f'{i:<10} {message[i]:<8} {ct[i]:<8} {recovered[i]:<10}')
Original:   THEQUICKBROWNFOXJUMPSOVERTHELAZYDOG
Encrypted:  JPWWPDDDRHVLWMAOENSZLSEBVSAMQHFWGLB
Re-encrypt: THEQUICKBROWNFOXJUMPSOVERTHELAZYDOG

Self-reciprocal verified: True

Character-by-character verification:
Position   Plain    Cipher   Recovered 
------------------------------------
0          T        J        T         
1          H        P        H         
2          E        W        E         
3          Q        W        Q         
4          U        P        U         
5          I        D        I         
6          C        D        C         
7          K        D        K         
8          B        R        B         
9          R        H        R         
10         O        V        O         
11         W        L        W         
12         N        W        N         
13         F        M        F         
14         O        A        O         
15         X        O        X         
16         J        E        J         
17         U        N        U         
18         M        S        M         
19         P        Z        P         
20         S        L        S         
21         O        S        O         
22         V        E        V         
23         E        B        E         
24         R        V        R         
25         T        S        T         
26         H        A        H         
27         E        M        E         
28         L        Q        L         
29         A        H        A         
30         Z        F        Z         
31         Y        W        Y         
32         D        G        D         
33         O        L        O         
34         G        B        G         

Experiment 3: No Letter Encrypts to Itself#

A fundamental consequence of the fixed-point-free reflector is that no letter can ever encrypt to itself. We verify this property by encrypting every letter of the alphabet with a fixed machine setting (creating a fresh machine for each letter to keep the same rotor position).

Hide code cell source
import numpy as np
import matplotlib.pyplot as plt

# Test all 26 letters at a fixed rotor position
# We create a fresh machine for EACH letter to keep the same rotor position
results = []
for i in range(26):
    enigma_test = make_enigma(positions=(0, 0, 0))
    plain_char = chr(i + ord('A'))
    cipher_char = enigma_test.encrypt_char(plain_char)
    is_fixed = (plain_char == cipher_char)
    results.append((plain_char, cipher_char, is_fixed))

print('Enigma encryption at position AAA (after one step to AAB):')
print(f'{"Input":<8} {"Output":<8} {"Fixed point?"}')
print('-' * 30)
for plain, cipher, fixed in results:
    marker = "YES !!!" if fixed else "no"
    print(f'  {plain:<8} {cipher:<8} {marker}')

fixed_count = sum(1 for _, _, f in results if f)
print(f'\nNumber of fixed points: {fixed_count}')
print(f'No-fixed-point property verified: {fixed_count == 0}')

# Visualization: permutation mapping
fig, ax = plt.subplots(figsize=(12, 4))

plain_letters = [r[0] for r in results]
cipher_letters = [r[1] for r in results]

# Draw input alphabet on top, output on bottom
y_top, y_bot = 1.0, 0.0
for i in range(26):
    ax.text(i, y_top, plain_letters[i], ha='center', va='center',
            fontsize=12, fontweight='bold', color='#1565C0',
            bbox=dict(boxstyle='round,pad=0.3', facecolor='#BBDEFB', edgecolor='#1565C0'))
    ax.text(i, y_bot, cipher_letters[i], ha='center', va='center',
            fontsize=12, fontweight='bold', color='#C62828',
            bbox=dict(boxstyle='round,pad=0.3', facecolor='#FFCDD2', edgecolor='#C62828'))
    # Draw connecting line
    ax.annotate('', xy=(i, y_bot + 0.15), xytext=(i, y_top - 0.15),
                arrowprops=dict(arrowstyle='->', color='#666666', lw=1.2, alpha=0.6))

ax.text(-1.5, y_top, 'Input:', ha='right', va='center', fontsize=11,
        fontweight='bold', color='#1565C0')
ax.text(-1.5, y_bot, 'Output:', ha='right', va='center', fontsize=11,
        fontweight='bold', color='#C62828')

ax.set_xlim(-2.5, 26.5)
ax.set_ylim(-0.5, 1.5)
ax.axis('off')
ax.set_title('Figure 10.1: Enigma Permutation at Position AAA -- No Letter Maps to Itself',
             fontsize=13, fontweight='bold', pad=15)

plt.tight_layout()
plt.savefig('fig_10_1_no_fixed_point.png', dpi=150, bbox_inches='tight', facecolor='white')
plt.show()
Enigma encryption at position AAA (after one step to AAB):
Input    Output   Fixed point?
------------------------------
  A        F        no
  B        W        no
  C        P        no
  D        O        no
  E        V        no
  F        A        no
  G        Y        no
  H        M        no
  I        L        no
  J        R        no
  K        N        no
  L        I        no
  M        H        no
  N        K        no
  O        D        no
  P        C        no
  Q        U        no
  R        J        no
  S        X        no
  T        Z        no
  U        Q        no
  V        E        no
  W        B        no
  X        S        no
  Y        G        no
  Z        T        no

Number of fixed points: 0
No-fixed-point property verified: True
../_images/ada35a9192385570cef100b044f07c75b85d3e4292b3684953753d621371f7a6.png

10.4 Key Properties#

Theorem 10.1 (Self-Reciprocal Property)#

Theorem. The Enigma permutation \(E = P \circ R_1^{-1} \circ R_2^{-1} \circ R_3^{-1} \circ U \circ R_3 \circ R_2 \circ R_1 \circ P\) is an involution: \(E^2 = \mathrm{id}\).

Theorem 10.2 (No Fixed Points)#

Theorem. If the reflector \(U\) is fixed-point-free (i.e., \(U(x) \neq x\) for all \(x\)), then the Enigma permutation \(E\) is also fixed-point-free: \(E(x) \neq x\) for all \(x\).

Remark

Both proofs rely on the conjugacy structure \(E = F^{-1} U F\). The key insight is that conjugation preserves the cycle structure of a permutation: if \(U\) is a product of 13 transpositions with no fixed points, then so is every conjugate \(F^{-1} U F\). This is a fundamental fact from the theory of permutation groups.

Keyspace Analysis#

The total number of distinct Enigma key settings (for the Wehrmacht Enigma I with 5 available rotors) is the product of several independent choices:

Component

Choices

Count

Rotor selection (3 from 5, ordered)

\(5 \times 4 \times 3\)

\(60\)

Rotor positions (Grundstellung)

\(26^3\)

\(17{,}576\)

Ring settings (Ringstellung)

\(26^3\) (one is redundant)

\(17{,}576\)

Plugboard (10 pairs from 26)

\(\binom{26}{2}\binom{24}{2}\cdots\binom{8}{2} / 10!\)

\(150{,}738{,}274{,}937{,}250\)

The plugboard count can be computed as:

\[ \frac{26!}{6! \cdot 2^{10} \cdot 10!} = 150{,}738{,}274{,}937{,}250\]

The total keyspace is approximately:

\[ 60 \times 17{,}576 \times 17{,}576 \times 150{,}738{,}274{,}937{,}250 \approx 1.59 \times 10^{20}\]

or roughly 67 bits of security – formidable for the 1930s, but ultimately insufficient against the combined power of mathematical insight and electromechanical computation.

Experiment 4: Rotor Stepping Visualization#

We visualize the odometer-like stepping of the three rotors over 100 keypresses, clearly showing the double-stepping anomaly of the middle rotor.

Hide code cell source
import numpy as np
import matplotlib.pyplot as plt

# Track rotor positions over 100 keystrokes
# Start right rotor near Q (notch) to trigger middle rotor stepping
enigma_step = make_enigma(
    rotor_names=('I', 'II', 'III'),
    positions=(14, 3, 0)  # O, D, A -- right rotor will hit Q notch soon
)

n_steps = 100
positions = np.zeros((n_steps, 3), dtype=int)

for i in range(n_steps):
    positions[i] = [r.position for r in enigma_step.rotors]
    enigma_step.step_rotors()

fig, axes = plt.subplots(3, 1, figsize=(14, 8), sharex=True)

rotor_labels = ['Right Rotor (I)', 'Middle Rotor (II)', 'Left Rotor (III)']
colors = ['#1976D2', '#E64A19', '#388E3C']
notch_positions = [16, 4, 21]  # Q, E, V

for idx, (ax, label, color, notch) in enumerate(zip(axes, rotor_labels, colors, notch_positions)):
    ax.step(range(n_steps), positions[:, idx], where='mid', color=color,
            linewidth=1.8, label=label)
    ax.axhline(y=notch, color=color, linestyle='--', alpha=0.4, linewidth=1)
    ax.text(n_steps + 0.5, notch, f'notch ({chr(notch + ord("A"))})',
            fontsize=8, color=color, va='center', alpha=0.7)
    ax.set_ylabel('Position', fontsize=11)
    ax.set_ylim(-1, 26)
    ax.set_yticks([0, 5, 10, 15, 20, 25])
    ax.set_yticklabels(['A(0)', 'F(5)', 'K(10)', 'P(15)', 'U(20)', 'Z(25)'], fontsize=8)
    ax.legend(loc='upper right', fontsize=10)
    ax.spines['top'].set_visible(False)
    ax.spines['right'].set_visible(False)
    ax.grid(axis='x', alpha=0.2)

axes[2].set_xlabel('Keypress Number', fontsize=12, fontweight='bold')
axes[0].set_title(
    'Figure 10.2: Enigma Rotor Stepping Over 100 Keypresses (Double-Stepping Visible)',
    fontsize=13, fontweight='bold')

# Mark double-stepping events
mid_steps = np.diff(positions[:, 1]) % 26
double_step_indices = []
for i in range(1, len(mid_steps)):
    if mid_steps[i] > 0 and mid_steps[i-1] > 0:
        double_step_indices.append(i)

for ds_idx in double_step_indices:
    for ax in axes:
        ax.axvline(x=ds_idx, color='red', linestyle=':', alpha=0.5, linewidth=1.5)
    axes[0].text(ds_idx, 25, 'double\nstep', fontsize=7, ha='center',
                 color='red', fontweight='bold', alpha=0.8)

plt.tight_layout()
plt.savefig('fig_10_2_rotor_stepping.png', dpi=150, bbox_inches='tight', facecolor='white')
plt.show()

# Print double-stepping events
if double_step_indices:
    print('Double-stepping anomaly detected at keypress positions:')
    for idx in double_step_indices:
        before = ''.join(chr(positions[idx-1, j] + ord('A')) for j in range(3))
        after = ''.join(chr(positions[idx, j] + ord('A')) for j in range(3))
        print(f'  Keypress {idx}: {before} -> {after}')
else:
    print('No double-stepping observed in this window.')
../_images/5b24ba81388e368a215f0b2fd65c8e6c71f16d52f4e57a285b243906adb56797.png
Double-stepping anomaly detected at keypress positions:
  Keypress 3: QDA -> REA

Experiment 5: Keyspace Comparison#

We compare the Enigma’s keyspace to other classical and modern ciphers, highlighting where the Enigma sits in the spectrum of cryptographic security.

Hide code cell source
import numpy as np
import matplotlib.pyplot as plt
from math import factorial, log2

# Compute exact Enigma plugboard count: 10 pairs from 26 letters
# Formula: 26! / (6! * 2^10 * 10!)
plugboard_10 = factorial(26) // (factorial(6) * (2**10) * factorial(10))

# Keyspace sizes
ciphers = {
    'Caesar cipher': 26,
    'Vigenere (key len 5)': 26**5,
    'Simple substitution': factorial(26),
    'Enigma (daily key)': 60 * 26**3 * 26**3 * plugboard_10,
    'DES': 2**56,
    'AES-128': 2**128,
    'AES-256': 2**256,
}

names = list(ciphers.keys())
log_sizes = [log2(v) for v in ciphers.values()]

fig, ax = plt.subplots(figsize=(11, 6))

colors = ['#90CAF9', '#64B5F6', '#42A5F5', '#FFD54F', '#FF8A65', '#EF5350', '#AB47BC']

bars = ax.barh(range(len(names)), log_sizes, color=colors, edgecolor='black', linewidth=0.8)

ax.set_yticks(range(len(names)))
ax.set_yticklabels(names, fontsize=11)
ax.set_xlabel('Key Space Size (bits = $\\log_2 |\\mathcal{K}|$)', fontsize=12, fontweight='bold')
ax.set_title('Figure 10.3: Enigma Keyspace Compared to Other Ciphers',
             fontsize=13, fontweight='bold')
ax.invert_yaxis()
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)

# Add labels
for i, (bar, bits) in enumerate(zip(bars, log_sizes)):
    ax.text(bits + 2, i, f'{float(bits):.1f} bits', va='center', fontsize=10, fontweight='bold')

# Security thresholds
ax.axvline(x=80, color='red', linestyle='--', alpha=0.5, linewidth=1.5)
ax.text(82, len(names) - 0.3, '80-bit\nsecurity', fontsize=9, color='red',
        va='top', style='italic')

ax.axvline(x=128, color='green', linestyle='--', alpha=0.5, linewidth=1.5)
ax.text(130, len(names) - 0.3, '128-bit\nsecurity', fontsize=9, color='green',
        va='top', style='italic')

# Highlight Enigma
bars[3].set_edgecolor('#E65100')
bars[3].set_linewidth(2.5)

plt.tight_layout()
plt.savefig('fig_10_3_keyspace_comparison.png', dpi=150, bbox_inches='tight', facecolor='white')
plt.show()

print(f'Exact Enigma keyspace: {ciphers["Enigma (daily key)"]:,}')
print(f'Enigma keyspace in bits: {float(log2(ciphers["Enigma (daily key)"])):.2f}')
print(f'Plugboard configurations (10 pairs): {plugboard_10:,}')
../_images/1304d9cede6b8863d11ba1d32e6c0e1a14f23df0697189f961f908bcdd972fea.png
Exact Enigma keyspace: 2,793,925,870,508,516,103,360,000
Enigma keyspace in bits: 81.21
Plugboard configurations (10 pairs): 150,738,274,937,250

Plugboard Combinatorics#

The plugboard is the single largest contributor to the Enigma’s keyspace. We compute and visualize the number of plugboard configurations as a function of the number of connected pairs.

Hide code cell source
import numpy as np
import matplotlib.pyplot as plt
from math import factorial, log2

def plugboard_count(s):
    # Number of plugboard configurations with exactly s pairs.
    # Formula: 26! / ((26-2s)! * 2^s * s!)
    if s == 0:
        return 1
    return factorial(26) // (factorial(26 - 2*s) * (2**s) * factorial(s))

# Compute for s = 0, 1, ..., 13
pairs = list(range(14))
counts = [plugboard_count(s) for s in pairs]
log_counts = [log2(c) if c > 0 else 0 for c in counts]

print('Plugboard configurations by number of pairs:')
print(f'{"Pairs (s)":<12} {"Configurations":<30} {"log2":<10}')
print('-' * 52)
for s, c, lc in zip(pairs, counts, log_counts):
    print(f'{s:<12} {c:>28,} {float(lc):>9.2f}')

# Find the maximum
max_idx = np.argmax(counts)
print(f'\nMaximum at s = {max_idx}: {counts[max_idx]:,} ({float(log_counts[max_idx]):.2f} bits)')
print(f'Standard wartime setting (s = 10): {counts[10]:,} ({float(log_counts[10]):.2f} bits)')

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))

# Linear scale
ax1.bar(pairs, counts, color='#42A5F5', edgecolor='black', linewidth=0.7)
ax1.bar(10, counts[10], color='#FFD54F', edgecolor='#E65100', linewidth=2,
        label='Wartime standard (10 pairs)')
ax1.bar(max_idx, counts[max_idx], color='#EF5350', edgecolor='#B71C1C', linewidth=2,
        label=f'Maximum ({max_idx} pairs)')
ax1.set_xlabel('Number of Plugboard Pairs ($s$)', fontsize=11)
ax1.set_ylabel('Number of Configurations', fontsize=11)
ax1.set_title('Plugboard Configs (Linear Scale)', fontsize=12, fontweight='bold')
ax1.legend(fontsize=9)
ax1.spines['top'].set_visible(False)
ax1.spines['right'].set_visible(False)

# Log scale
ax2.bar(pairs, log_counts, color='#66BB6A', edgecolor='black', linewidth=0.7)
ax2.bar(10, log_counts[10], color='#FFD54F', edgecolor='#E65100', linewidth=2,
        label='Wartime standard (10 pairs)')
ax2.bar(max_idx, log_counts[max_idx], color='#EF5350', edgecolor='#B71C1C', linewidth=2,
        label=f'Maximum ({max_idx} pairs)')
ax2.set_xlabel('Number of Plugboard Pairs ($s$)', fontsize=11)
ax2.set_ylabel('$\\log_2$(Configurations) [bits]', fontsize=11)
ax2.set_title('Plugboard Configs (Log Scale)', fontsize=12, fontweight='bold')
ax2.legend(fontsize=9)
ax2.spines['top'].set_visible(False)
ax2.spines['right'].set_visible(False)

fig.suptitle('Figure 10.4: Plugboard Combinatorics', fontsize=14, fontweight='bold', y=1.02)

plt.tight_layout()
plt.savefig('fig_10_4_plugboard_combinatorics.png', dpi=150, bbox_inches='tight', facecolor='white')
plt.show()
Plugboard configurations by number of pairs:
Pairs (s)    Configurations                 log2      
----------------------------------------------------
0                                       1      0.00
1                                     325      8.34
2                                  44,850     15.45
3                               3,453,450     21.72
4                             164,038,875     27.29
5                           5,019,589,575     32.22
6                         100,391,791,500     36.55
7                       1,305,093,289,500     40.25
8                      10,767,019,638,375     43.29
9                      53,835,098,191,875     45.61
10                    150,738,274,937,250     47.10
11                    205,552,193,096,250     47.55
12                    102,776,096,548,125     46.55
13                      7,905,853,580,625     42.85

Maximum at s = 11: 205,552,193,096,250 (47.55 bits)
Standard wartime setting (s = 10): 150,738,274,937,250 (47.10 bits)
../_images/48ce5a7f71d08d410636e4b6f60c81111704051caf1b6bf881a223e16d8ec74f.png

Permutation Analysis: Cycle Structure of the Enigma#

We proved above that the Enigma permutation \(E\) is always an involution (\(E^2 = \mathrm{id}\)) and always fixed-point-free (no letter encrypts to itself). An involution decomposes into disjoint transpositions (2-cycles) and fixed points. Since \(E\) has no fixed points on a 26-letter alphabet, it consists of exactly 13 transpositions. We verify this computationally by examining the cycle structure at many random rotor positions.

Hide code cell source
import numpy as np
import matplotlib.pyplot as plt

def get_enigma_permutation_snapshot(rotor_names, reflector_name, positions, plugboard_pairs):
    # Get the full 26-element permutation at a given position
    # (stepping once before encryption, as the real Enigma does)
    perm = np.zeros(26, dtype=int)
    for i in range(26):
        e = make_enigma(rotor_names=rotor_names, reflector_name=reflector_name,
                        positions=positions, plugboard_pairs=plugboard_pairs)
        perm[i] = ord(e.encrypt_char(chr(i + ord('A')))) - ord('A')
    return perm


def cycle_structure(perm):
    # Return the cycle structure as a sorted list of cycle lengths
    n = len(perm)
    visited = np.zeros(n, dtype=bool)
    cycles = []
    for i in range(n):
        if not visited[i]:
            cycle_len = 0
            j = i
            while not visited[j]:
                visited[j] = True
                j = perm[j]
                cycle_len += 1
            cycles.append(cycle_len)
    return sorted(cycles, reverse=True)


# Analyze cycle structure at many different rotor positions
np.random.seed(42)
n_samples = 500
plug_pairs = [('A','R'), ('B','Q'), ('C','Y'), ('D','K'), ('E','W'),
              ('F','T'), ('G','N'), ('H','X'), ('I','L'), ('O','S')]

all_transposition_counts = []
all_fixed_point_counts = []

for _ in range(n_samples):
    pos = tuple(np.random.randint(0, 26, size=3).tolist())
    perm = get_enigma_permutation_snapshot(('I', 'II', 'III'), 'B', pos, plug_pairs)
    cycles = cycle_structure(perm)
    n_fixed = cycles.count(1)
    n_trans = cycles.count(2)
    all_transposition_counts.append(n_trans)
    all_fixed_point_counts.append(n_fixed)

print(f'Analysis of {n_samples} random Enigma permutations:')
print(f'  Total fixed points found: {sum(all_fixed_point_counts)}')
print(f'  Average transposition count: {float(np.mean(all_transposition_counts)):.2f}')
print(f'  All have exactly 13 transpositions: '
      f'{all(t == 13 for t in all_transposition_counts)}')

# Visualization
fig, ax = plt.subplots(figsize=(8, 5))

unique_counts, freq = np.unique(all_transposition_counts, return_counts=True)
ax.bar(unique_counts, freq, color='#42A5F5', edgecolor='black', linewidth=0.8, width=0.6)

ax.set_xlabel('Number of 2-cycles (transpositions)', fontsize=12)
ax.set_ylabel('Frequency', fontsize=12)
ax.set_title(f'Figure 10.5: Cycle Structure of {n_samples} Random Enigma Permutations\n'
             f'(All are products of exactly 13 transpositions, 0 fixed points)',
             fontsize=12, fontweight='bold')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)

plt.tight_layout()
plt.savefig('fig_10_5_cycle_structure.png', dpi=150, bbox_inches='tight', facecolor='white')
plt.show()
Analysis of 500 random Enigma permutations:
  Total fixed points found: 0
  Average transposition count: 13.00
  All have exactly 13 transpositions: True
../_images/eaedcc2148afc0b23b74922400d383c28890a5b4ce4a5f0b02229c35253a1dbc.png

Signal Path Visualization#

We trace the signal path through the Enigma machine for a single character, showing how the electrical current passes through the plugboard, each rotor (forward), the reflector, each rotor (backward), and the plugboard again.

Hide code cell source
import numpy as np
import matplotlib.pyplot as plt

def trace_signal(enigma, char):
    # Trace the signal path through the Enigma for a single character.
    # Returns a list of (stage_name, value) tuples.
    enigma.step_rotors()

    x = ord(char) - ord('A')
    trace = [('Input', x)]

    x = enigma.plugboard.swap(x)
    trace.append(('Plugboard', x))

    for i, rotor in enumerate(enigma.rotors):
        x = rotor.forward(x)
        trace.append((f'{rotor.name} fwd', x))

    x = enigma.reflector.reflect(x)
    trace.append(('Reflector', x))

    for i, rotor in enumerate(reversed(enigma.rotors)):
        x = rotor.backward(x)
        trace.append((f'{rotor.name} bwd', x))

    x = enigma.plugboard.swap(x)
    trace.append(('Plugboard out', x))
    trace.append(('Output', x))

    return trace


enigma_trace = make_enigma(
    positions=(0, 0, 0),
    plugboard_pairs=[('A','Z'), ('B','Y'), ('C','X')]
)

input_char = 'H'
trace = trace_signal(enigma_trace, input_char)

print(f'Signal trace for input \'{input_char}\':')
print('-' * 45)
for stage, val in trace:
    print(f'  {stage:<20s}  {chr(val + ord("A"))} ({val})')

# Visualization
fig, ax = plt.subplots(figsize=(14, 6))

stages = [t[0] for t in trace]
values = [t[1] for t in trace]
letters = [chr(v + ord('A')) for v in values]

n = len(stages)
x_pos = np.linspace(0, 13, n)

# Color the stages
stage_colors = ['#1565C0', '#7B1FA2',
                '#E65100', '#E65100', '#E65100',
                '#C62828',
                '#2E7D32', '#2E7D32', '#2E7D32',
                '#7B1FA2', '#1565C0']

for i in range(n):
    ax.plot(x_pos[i], values[i], 'o', color=stage_colors[i], markersize=14,
            markeredgecolor='black', markeredgewidth=1.2, zorder=5)
    ax.text(x_pos[i], values[i] + 1.5, letters[i], ha='center', va='bottom',
            fontsize=12, fontweight='bold', color=stage_colors[i])
    ax.text(x_pos[i], -3, stages[i], ha='center', va='top', fontsize=8,
            rotation=35, color=stage_colors[i], fontweight='bold')

# Connect with lines
for i in range(n - 1):
    ax.plot([x_pos[i], x_pos[i+1]], [values[i], values[i+1]],
            '-', color='#999999', linewidth=1.5, alpha=0.6, zorder=1)

ax.set_ylabel('Letter Value (0=A, 25=Z)', fontsize=11)
ax.set_ylim(-6, 28)
ax.set_xlim(-0.5, 13.5)
ax.set_yticks(range(0, 26, 5))
ax.set_yticklabels([f'{chr(i+65)} ({i})' for i in range(0, 26, 5)], fontsize=9)
ax.set_xticks([])
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['bottom'].set_visible(False)

# Add direction arrows
ax.annotate('', xy=(6.5, 27), xytext=(0.5, 27),
            arrowprops=dict(arrowstyle='->', color='#E65100', lw=2))
ax.text(3.5, 27.8, 'Forward path', fontsize=10, ha='center',
        color='#E65100', fontweight='bold')

ax.annotate('', xy=(12.5, 27), xytext=(7, 27),
            arrowprops=dict(arrowstyle='->', color='#2E7D32', lw=2))
ax.text(9.75, 27.8, 'Return path', fontsize=10, ha='center',
        color='#2E7D32', fontweight='bold')

ax.set_title(f'Figure 10.6: Signal Path Through the Enigma for Input \'{input_char}\'',
             fontsize=13, fontweight='bold', pad=20)

plt.tight_layout()
plt.savefig('fig_10_6_signal_path.png', dpi=150, bbox_inches='tight', facecolor='white')
plt.show()
Signal trace for input 'H':
---------------------------------------------
  Input                 H (7)
  Plugboard             H (7)
  Rotor I fwd           U (20)
  Rotor II fwd          P (15)
  Rotor III fwd         E (4)
  Reflector             Q (16)
  Rotor III bwd         Y (24)
  Rotor II bwd          V (21)
  Rotor I bwd           M (12)
  Plugboard out         M (12)
  Output                M (12)
../_images/ae5f5c3b594de913e0175108ce173ab23c59061a8ca3a7ad89b9a68a53fef429.png

Extended Demonstration: A Historical-Style Message#

We encrypt a message representative of wartime German military communications, using realistic Enigma settings.

import numpy as np

# Realistic wartime settings
# Day key: Rotors II, IV, V; Reflector B; Ring settings: B-U-L (1-20-11)
# Plugboard: AV BS CG DL FU HZ IN KM OW RX
# Start positions: P-D-A

enigma_war = make_enigma(
    rotor_names=('II', 'IV', 'V'),
    reflector_name='B',
    ring_settings=(1, 20, 11),
    positions=(15, 3, 0),  # P, D, A
    plugboard_pairs=[('A','V'), ('B','S'), ('C','G'), ('D','L'),
                     ('F','U'), ('H','Z'), ('I','N'), ('K','M'),
                     ('O','W'), ('R','X')]
)

# A typical weather report format
plaintext = 'WETTERVORHERSAGENORDSEEKEINBESONDERESVORKOMMNISDREIXDREIX'
ciphertext = enigma_war.encrypt(plaintext)

print('=' * 65)
print('ENIGMA ENCRYPTED MESSAGE')
print('=' * 65)
print(f'Date key:      Rotors II-IV-V, Reflector B')
print(f'Ring settings: B-U-L (1-20-11)')
print(f'Start pos:     P-D-A')
print(f'Plugboard:     AV BS CG DL FU HZ IN KM OW RX')
print('=' * 65)
print(f'Plaintext:     {plaintext}')
print(f'Ciphertext:    {ciphertext}')
print('=' * 65)

# Decrypt
enigma_war_dec = make_enigma(
    rotor_names=('II', 'IV', 'V'),
    reflector_name='B',
    ring_settings=(1, 20, 11),
    positions=(15, 3, 0),
    plugboard_pairs=[('A','V'), ('B','S'), ('C','G'), ('D','L'),
                     ('F','U'), ('H','Z'), ('I','N'), ('K','M'),
                     ('O','W'), ('R','X')]
)

recovered = enigma_war_dec.encrypt(ciphertext)
print(f'Recovered:     {recovered}')
print(f'Match:         {recovered == plaintext}')

# Show no letter encrypts to itself in this message
print(f'\nCharacter-by-character (first 20):')
print(f'{"Pos":<5} {"Plain":<7} {"Cipher":<7} {"Same?":<6}')
print('-' * 25)
for i in range(min(20, len(plaintext))):
    same = 'YES!' if plaintext[i] == ciphertext[i] else ''
    print(f'{i:<5} {plaintext[i]:<7} {ciphertext[i]:<7} {same}')
=================================================================
ENIGMA ENCRYPTED MESSAGE
=================================================================
Date key:      Rotors II-IV-V, Reflector B
Ring settings: B-U-L (1-20-11)
Start pos:     P-D-A
Plugboard:     AV BS CG DL FU HZ IN KM OW RX
=================================================================
Plaintext:     WETTERVORHERSAGENORDSEEKEINBESONDERESVORKOMMNISDREIXDREIX
Ciphertext:    UCMWMMREJJRVLNLPTDYFLYPCRHJSFQBDYLQPENJBYNXAQNOKMTURSMWNG
=================================================================
Recovered:     WETTERVORHERSAGENORDSEEKEINBESONDERESVORKOMMNISDREIXDREIX
Match:         True

Character-by-character (first 20):
Pos   Plain   Cipher  Same? 
-------------------------
0     W       U       
1     E       C       
2     T       M       
3     T       W       
4     E       M       
5     R       M       
6     V       R       
7     O       E       
8     R       J       
9     H       J       
10    E       R       
11    R       V       
12    S       L       
13    A       N       
14    G       L       
15    E       P       
16    N       T       
17    O       D       
18    R       Y       
19    D       F       

Period of the Enigma#

Unlike simple polyalphabetic ciphers (e.g., Vigenere with a fixed period), the Enigma’s period depends on the rotor stepping mechanism. With three rotors of 26 positions each and the double-stepping anomaly, the theoretical period before the machine returns to an identical state is less than \(26^3 = 17{,}576\). We compute the actual period for several rotor configurations.

import numpy as np

def enigma_period(rotor_names, reflector_name='B', start_pos=(0, 0, 0)):
    # Find the period: number of steps before rotor positions return to initial state
    enigma = make_enigma(rotor_names=rotor_names, reflector_name=reflector_name,
                          positions=start_pos)

    initial = tuple(r.position for r in enigma.rotors)
    enigma.step_rotors()
    count = 1

    while tuple(r.position for r in enigma.rotors) != initial:
        enigma.step_rotors()
        count += 1
        if count > 26**3 + 100:  # safety limit
            return -1

    return count

# Test several configurations
configs = [
    ('I', 'II', 'III'),
    ('I', 'II', 'IV'),
    ('I', 'II', 'V'),
    ('II', 'III', 'IV'),
    ('III', 'IV', 'V'),
    ('I', 'III', 'V'),
]

print('Enigma Rotor Periods')
print(f'{"Rotors":<18} {"Period":<10} {"26^3":<10} {"Ratio":<10}')
print('-' * 48)
for cfg in configs:
    period = enigma_period(cfg)
    ratio = period / (26**3)
    print(f'{"--".join(cfg):<18} {period:<10} {26**3:<10} {ratio:<10.4f}')

print(f'\nNote: The period is less than 26^3 = {26**3:,} due to the double-stepping anomaly.')
print('Without double-stepping, the period would be exactly 26^3 = 17,576.')
Enigma Rotor Periods
Rotors             Period     26^3       Ratio     
------------------------------------------------
I--II--III         16900      17576      0.9615    
I--II--IV          16900      17576      0.9615    
I--II--V           16900      17576      0.9615    
II--III--IV        16900      17576      0.9615    
III--IV--V         16900      17576      0.9615    
I--III--V          16900      17576      0.9615    

Note: The period is less than 26^3 = 17,576 due to the double-stepping anomaly.
Without double-stepping, the period would be exactly 26^3 = 17,576.

10.5 Exercises#


Exercise 10.1 (Double-Stepping Anomaly). Using the Enigma simulator with Rotors I, II, III and Reflector B (no plugboard), find all rotor positions where the double-stepping anomaly occurs. Specifically:

(a) Starting from position AAA, step through all \(26^3\) positions and record every position where the middle rotor steps on two consecutive keypresses.

(b) How many such “double-step events” occur in a full cycle?

(c) Explain why the double-stepping anomaly reduces the period of the rotor system below \(26^3\).


Exercise 10.2 (Plugboard Combinatorics). Compute the exact number of plugboard configurations with exactly \(s\) pairs for \(s = 0, 1, \ldots, 13\).

(a) Derive the closed-form formula: \(\displaystyle N(s) = \frac{26!}{(26 - 2s)!\, \cdot\, 2^s\, \cdot\, s!}\)

(b) For which value of \(s\) is \(N(s)\) maximized?

(c) Show that \(\displaystyle \sum_{s=0}^{13} N(s) = \sum_{k=0}^{13} \frac{26!}{(26-2k)!\, 2^k\, k!}\) and compute this total.


Exercise 10.3 (Reflector with a Fixed Point). Suppose the reflector \(U\) had a fixed point, i.e., \(U(x_0) = x_0\) for some \(x_0 \in \mathbb{Z}_{26}\).

(a) Show that the Enigma permutation \(E = F^{-1} U F\) would then have a fixed point at \(F^{-1}(x_0)\).

(b) How would this affect the crib-based attacks used at Bletchley Park? Be specific about the impact on the Bombe’s menu construction.

(c) Would the Enigma still be self-reciprocal? Prove or disprove.


Exercise 10.4 (Naval Enigma M4). Implement a 4-rotor Naval Enigma (M4) simulator. The M4 used a thin fourth rotor (Beta or Gamma) that did not step, combined with a thin reflector (B-thin or C-thin).

(a) Add the following thin rotor wirings:

  • Beta: LEYJVCNIXWPBQMDRTAKZGFUHOS

  • Gamma: FSOKANUERHMBTIYCWLQPZXVGJD

  • UKW-B thin: ENKQAUYWJICOPBLMDXZVFTHRGS

  • UKW-C thin: RDOBJNTKVEHMLFCWZAXGYIPSUQ

(b) Verify that your M4 simulator correctly encrypts a test message and decrypts it back to the original.


Exercise 10.5 (Challenge: Fixed-Point-Free Involution Conjugates). Prove the following theorem:

Theorem. Let \(U : \mathbb{Z}_n \to \mathbb{Z}_n\) be a fixed-point-free involution (where \(n\) is even), and let \(F : \mathbb{Z}_n \to \mathbb{Z}_n\) be any permutation. Let \(P : \mathbb{Z}_n \to \mathbb{Z}_n\) be any involution (possibly with fixed points). Then the permutation \(E = P \circ F^{-1} \circ U \circ F \circ P\) is a fixed-point-free involution.

This theorem captures the essential algebraic reason why the Enigma can never encrypt a letter to itself.

10.6 Summary and Key Takeaways#

This chapter has provided a complete mathematical and computational treatment of the Enigma machine’s design.

  • The Enigma is a polyalphabetic substitution cipher implemented electromechanically through a cascade of permutations: plugboard, three (or four) rotors, and a reflector. The rotor stepping mechanism ensures that the substitution alphabet changes with every keypress.

  • The reflector creates both the Enigma’s greatest convenience and its greatest weakness. It makes the machine self-reciprocal (\(E = E^{-1}\)), eliminating the need for separate encryption and decryption procedures. But it also guarantees that no letter can encrypt to itself – a property ruthlessly exploited by Allied cryptanalysts.

  • The keyspace is vast but not impregnable. With approximately \(1.59 \times 10^{20}\) possible daily settings (~67 bits), brute-force search was infeasible with 1940s technology. However, mathematical structure – particularly the permutation-group properties of the rotors and the constraints imposed by the reflector – provided cryptanalysts with powerful analytical tools.

  • The double-stepping anomaly is a mechanical artifact that reduces the rotor period below the theoretical \(26^3\), introducing a subtle but detectable regularity.

  • Algebraic structure is the key. The factorization \(E = F^{-1} U F\) (conjugation by the forward path) connects the Enigma’s properties directly to classical permutation group theory. Rejewski’s great insight was to exploit this algebraic structure rather than attempting brute-force search.

Tip

Looking ahead. In Chapter 11, we will study how Marian Rejewski and the Polish Biuro Szyfrow exploited the Enigma’s mathematical structure – particularly the permutation equations arising from the repeated indicator procedure – to recover the rotor wirings and daily keys. In Chapter 12, we will examine how Turing and Welchman at Bletchley Park built upon the Polish work to develop the Bombe.

10.7 References#

  1. Arthur Scherbius, German Patent DE416219, “Chiffrierapparat” (Cipher apparatus), filed February 23, 1918. The original patent for the Enigma machine.

  2. Simon Singh, The Code Book: The Science of Secrecy from Ancient Egypt to Quantum Cryptography, Anchor Books, 2000. Chapters 4–6 provide an excellent accessible account of the Enigma, the Polish codebreakers, and Bletchley Park.

  3. Marian Rejewski, “An Application of the Theory of Permutations in Breaking the Enigma Cipher,” Applicationes Mathematicae, 16(4), pp. 543–559, 1980. Rejewski’s own account of how he used permutation theory to break the Enigma in 1932. Essential reading for understanding the mathematical foundations of the attack.

  4. Gordon Welchman, The Hut Six Story: Breaking the Enigma Codes, McGraw-Hill, 1982. Welchman’s firsthand account of codebreaking at Bletchley Park, including his invention of the diagonal board that dramatically improved the Bombe’s efficiency.

  5. B. Jack Copeland (editor), Colossus: The Secrets of Bletchley Park’s Codebreaking Computers, Oxford University Press, 2006. A comprehensive technical and historical account of the codebreaking effort at Bletchley Park, covering both Enigma and the Lorenz cipher.

  6. Dermot Turing, X, Y & Z: The Real Story of How Enigma Was Broken, The History Press, 2018. A detailed account of the Polish-French-British collaboration in breaking the Enigma, with particular attention to the often-overlooked contributions of the Polish mathematicians.

  7. Friedrich L. Bauer, Decrypted Secrets: Methods and Maxims of Cryptology, 4th edition, Springer, 2007. A rigorous mathematical treatment of historical cipher machines, including extensive analysis of the Enigma’s permutation structure.