CHAPTER ONE: THE ENIGMA MACHINE — HISTORY AND CONTEXT
The Enigma machine stands as one of the most remarkable inventions in the history of cryptography. Developed by German engineer Arthur Scherbius in the early 1920s, it was initially marketed as a commercial product for banks and businesses that needed to protect sensitive communications. The German military recognized its potential and adopted it in the late 1920s, eventually deploying it across all branches of the Wehrmacht, the Kriegsmarine, and the Luftwaffe during World War II. By the height of the war, hundreds of thousands of Enigma machines were in operation, and the Germans believed with absolute confidence that the cipher it produced was mathematically unbreakable.
They were wrong, but only barely, and only because of extraordinary human ingenuity at Bletchley Park in England, where Alan Turing, Gordon Welchman, Marian Rejewski, and their colleagues developed the Bombe machine and the mathematical techniques needed to crack Enigma-encrypted messages. The story of breaking Enigma is one of the most consequential intellectual achievements of the twentieth century, and historians estimate that it shortened the war by at least two years, saving millions of lives.
What makes Enigma so fascinating from a software engineering perspective is that it is fundamentally a mechanical implementation of a mathematical concept: polyalphabetic substitution with a dynamically changing key. Every single keystroke changes the internal state of the machine, meaning that the same letter pressed twice in succession will almost never produce the same ciphertext letter twice. This property, called non-deterministic substitution, is what made Enigma so powerful and so difficult to break.
In this article we will build a complete, faithful software implementation of the Enigma machine in Python. We will implement the full graphical interface that looks and behaves like the real machine, complete with animations of the rotors turning, lights illuminating on the lampboard, and keys depressing on the keyboard. We will then extend the application with a REST-based networking layer so that two users on the same network or across the internet can connect their virtual Enigma machines and exchange encrypted messages in real time, just as radio operators did during the war.
Before we write a single line of code, however, we need to understand the mathematics and cryptography that make Enigma work. This understanding is not optional; it is the foundation upon which every design decision in our implementation rests.
CHAPTER TWO: THE MATHEMATICAL AND CRYPTOGRAPHIC FOUNDATIONS
2.1 Substitution Ciphers and Their Limitations
The simplest form of encryption is the monoalphabetic substitution cipher, which has been used since antiquity. In a substitution cipher, each letter of the alphabet is replaced by another letter according to a fixed mapping. The Caesar cipher, for example, shifts every letter by a fixed number of positions in the alphabet. If the shift is 3, then A becomes D, B becomes E, C becomes F, and so on.
We can express a substitution cipher mathematically as a permutation function. A permutation of the alphabet is a bijective function from the set {A, B, C, ..., Z} to itself, meaning every input letter maps to exactly one output letter, and every output letter is the image of exactly one input letter. If we number the letters 0 through 25, a permutation sigma maps each number i to a unique number sigma(i). For the Caesar cipher with shift k, the permutation is sigma(i) = (i + k) mod 26.
The critical weakness of monoalphabetic substitution is that it preserves letter frequency. In English text, the letter E appears roughly 12.7 percent of the time, T appears about 9.1 percent, A appears about 8.2 percent, and so on. If you encrypt a long English message with a Caesar cipher, the most frequent letter in the ciphertext will correspond to E in the plaintext. A cryptanalyst can break any monoalphabetic cipher with a few hundred characters of ciphertext simply by performing frequency analysis.
2.2 Polyalphabetic Substitution and the Vigenere Cipher
The solution to the frequency analysis problem is to use multiple different substitution alphabets, switching between them as you encrypt each letter. This is called polyalphabetic substitution. The Vigenere cipher, invented in the sixteenth century, uses a keyword to determine which of 26 Caesar ciphers to apply to each letter. If the keyword is KEY, then the first letter of the plaintext is encrypted with a shift of 10 (K is the eleventh letter, index 10), the second with a shift of 4 (E), the third with a shift of 24 (Y), and then the pattern repeats. This means that the letter E in the plaintext might be encrypted as O in one position, I in another, and C in a third, depending on where it falls relative to the keyword.
Mathematically, if the keyword has length n and the keyword letters have indices k_0, k_1, ..., k_{n-1}, then the i-th plaintext letter p_i is encrypted as c_i = (p_i + k_{i mod n}) mod 26. The Vigenere cipher resisted cryptanalysis for three centuries and was called "le chiffre indechiffrable" — the unbreakable cipher. Charles Babbage and Friedrich Kasiski independently discovered how to break it in the nineteenth century by finding repeated sequences in the ciphertext, which reveal the keyword length, and then applying frequency analysis separately to each position.
2.3 The Key Insight Behind Enigma: Permutation Composition
Enigma takes polyalphabetic substitution to its logical extreme. Instead of cycling through a small set of substitution alphabets, Enigma changes its substitution alphabet with every single keystroke. After 26 keystrokes, the first rotor has made a full revolution and the second rotor steps by one position. The total number of distinct substitution alphabets available to a standard three-rotor Enigma is enormous.
The mathematical operation at the heart of Enigma is the composition of permutations. If sigma and tau are two permutations of the alphabet, their composition (tau composed with sigma) is the permutation that first applies sigma and then applies tau. In mathematical notation, (tau composed with sigma)(i) = tau(sigma(i)).
Each rotor in the Enigma machine implements a permutation of the 26 letters. The electrical signal representing a keypress passes through multiple rotors in sequence, each applying its permutation to the signal. The signal then hits the reflector, which applies another permutation, and then travels back through the rotors in reverse order, applying the inverse permutations. This is a crucial point: the reflector ensures that the composition of all the permutations is an involution, meaning a permutation that is its own inverse. If encrypting the letter A produces C, then encrypting C produces A. This property is what made Enigma both useful (the same machine settings decrypt as encrypt) and vulnerable (a letter can never encrypt to itself, which gave codebreakers a powerful constraint to exploit).
2.4 The Permutation Group Structure
Let us think about this more carefully using group theory. The set of all permutations of 26 elements forms a mathematical group called the symmetric group S_26. This group has 26 factorial elements, which is approximately 4 times 10 to the power of 26. The group operation is composition of permutations.
Each rotor defines a specific element of S_26. Let us call the permutations of the three rotors R_1, R_2, and R_3, the reflector permutation U (from the German Umkehrwalze), and the plugboard permutation P. The plugboard is a set of cables that swap pairs of letters before and after the rotor assembly.
When a key is pressed, the electrical signal passes through the following sequence of transformations. First, the plugboard applies P. Then the signal enters the rotor assembly from the right and passes through R_1 (right rotor), then R_2 (middle rotor), then R_3 (left rotor). The reflector applies U. The signal then returns through the rotors in reverse, applying the inverse permutations R_3 inverse, R_2 inverse, R_1 inverse. Finally, the plugboard applies P again. Since P is its own inverse (it just swaps pairs), applying it twice returns the signal to its pre-plugboard state, which then illuminates the correct lamp.
The complete encryption permutation for a given machine state is the composition of all these transformations. But this is not the full picture, because the rotors are not fixed; they rotate. Each rotor has an offset, which is the number of positions it has rotated from its starting position. If rotor R has offset d and ring setting r, then the effective permutation it applies to a signal entering at position i is: output = (W((i + d - r) mod 26) - d + r) mod 26, where W is the internal wiring array. This formula encapsulates the entire rotor transformation: the signal is first adjusted for the offset and ring setting, passed through the internal wiring, and then adjusted back to the fixed reference frame.
2.5 The Stepping Mechanism and Period
The Enigma's rotors step according to a mechanism called odometer stepping. The rightmost rotor steps with every keypress. When the rightmost rotor reaches a special position called its notch position, it causes the middle rotor to step on the next keypress. Similarly, when the middle rotor reaches its notch position, it causes both itself and the left rotor to step on the next keypress. This is the famous double-stepping anomaly that is unique to Enigma and was a consequence of the mechanical design of the pawl-and-ratchet stepping mechanism.
The period of the Enigma machine, meaning the number of keystrokes before the rotor configuration returns to its starting state, is 26 times 26 times 26, which equals 17,576 keystrokes. For practical military communications, which rarely exceeded a few hundred characters per message, this was effectively infinite.
2.6 The Plugboard and Its Combinatorial Explosion
The plugboard (Steckerbrett in German) was added to the military Enigma to dramatically increase the number of possible machine configurations. The plugboard sits between the keyboard and the rotor assembly. It contains 26 sockets, one for each letter, and operators could connect pairs of letters using cables. A cable connecting A and B means that when A is pressed, the signal entering the rotor assembly is treated as B, and vice versa.
The number of ways to choose 10 pairs from 26 letters (the standard military configuration used 10 cables) is approximately 150 trillion. This is an astronomically large number. Combined with the rotor choices (3 rotors chosen from 5, in order, giving 60 combinations), the ring settings (26 cubed = 17,576 combinations), and the initial rotor positions (26 cubed = 17,576 combinations), the total number of possible Enigma configurations is approximately 10 to the power of 23. This is the number that the Germans believed made Enigma unbreakable.
2.7 Why Enigma Was Breakable Despite Its Complexity
The mathematical structure that made Enigma breakable was precisely the involution property of the reflector. Because a letter can never encrypt to itself, and because the plugboard swaps pairs, the codebreakers could use a technique called cribs, which are known or guessed plaintext fragments. If they suspected that a message began with "WETTER" (weather), they could slide this word along the ciphertext and check each position: does any position produce a configuration where W encrypts to the first ciphertext letter, E to the second, and so on, without any letter encrypting to itself? Positions where a letter would have to encrypt to itself are immediately eliminated.
The Bombe machine automated this process, testing thousands of rotor configurations per second and eliminating those that were inconsistent with the crib. The double-stepping anomaly, operator errors such as using predictable message keys, and stereotyped message formats all provided additional mathematical footholds. Understanding these mathematical foundations is essential for our implementation, because we need to implement the permutation composition correctly, handle the rotor offsets and ring settings precisely, and implement the double-stepping mechanism faithfully.
CHAPTER THREE: ARCHITECTURE OF THE SOFTWARE ENIGMA
3.1 Overall Design Philosophy
Our software Enigma will be structured as a layered application. The innermost layer is the pure cryptographic engine, which implements the Enigma's mathematical operations with no dependencies on any graphical or networking code. This layer is a Python class hierarchy that models the plugboard, individual rotors, the reflector, and the complete machine. It is fully testable in isolation and can be verified against known Enigma test vectors.
The second layer is the graphical interface, built with Tkinter and the Canvas widget. This layer renders a faithful visual representation of the Enigma machine, including the keyboard, the lampboard, the rotor windows showing the current positions, and the plugboard. It uses Tkinter's animation capabilities to simulate the physical behavior of the machine: keys press down and spring back up, lamps illuminate and fade, and rotors visibly advance when a key is pressed.
The third layer is the networking layer, which implements a REST API using Flask on the server side and the requests library on the client side. The design is fully bidirectional: both the server operator and the client operator can type characters, and each sees the other's encrypted output on their lampboard in real time. The server maintains two queues — one for characters it receives from the client (displayed on the server's lampboard immediately via a callback), and one for characters the server operator types (placed in an outgoing queue for the client to retrieve via polling). The client sends typed characters directly to the server's receive endpoint and polls the server's outgoing queue to retrieve characters the server operator typed.
The fourth layer is the application layer, which ties everything together. It handles user interactions, coordinates between the graphical and networking layers, and manages the session state.
This layered architecture follows the clean architecture principle of dependency inversion: the inner layers know nothing about the outer layers. The cryptographic engine does not import Tkinter. The graphical layer does not import Flask. Each layer communicates with adjacent layers through well-defined interfaces.
3.2 The Enigma Rotor Specifications
Before we can write the cryptographic engine, we need the actual wiring specifications of the historical Enigma rotors. These are the permutations that each rotor implements, expressed as a string of 26 letters where the i-th letter is the output for input i (with A=0, B=1, and so on up to Z=25).
The Wehrmacht and Luftwaffe rotors, with their notch positions, are as follows. Rotor I has wiring EKMFLGDQVZNTOWYHXUSPAIBRCJ and its notch is at Y, meaning it causes the next rotor to step when moving from Y to Z. Rotor II has wiring AJDKSIRUXBLHWTMCQGZNPYFVOE with its notch at M. Rotor III has wiring BDFHJLCPRTXVZNYEIWGAKMUSQO with its notch at V. Rotor IV has wiring ESOVPZJAYQUIRHXLNFTGKDCMWB with its notch at J. Rotor V has wiring VZBRGITYUPSDNHLXAWMJQOFECK with its notch at Z. Reflector B (UKW-B) has wiring YRUHQSLDPXNGOKMIEBFZCWVJAT, and Reflector C (UKW-C) has wiring FVPJIAOYEDRZXWGCTKUQSBNMHL. These strings encode the physical wiring of each rotor and are sourced from the Crypto Museum (cryptomuseum.com) and verified against David Kahn's "Seizing the Enigma."
CHAPTER FOUR: BUILDING THE CRYPTOGRAPHIC ENGINE
Now we can begin writing code. We start with the foundation: the cryptographic engine. This module has no imports from outside the standard library, and absolutely no graphical or networking dependencies. The complete file is enigma_engine.py.
# enigma_engine.py
#
# The core cryptographic engine for the Enigma machine simulation.
# This module implements the mathematical permutation operations
# that define the Enigma cipher, following clean architecture
# principles with no dependencies on UI or networking layers.
#
# Historical rotor wirings sourced from the Crypto Museum
# (cryptomuseum.com) and verified against:
# "Seizing the Enigma" by David Kahn.
#
# Usage:
# from enigma_engine import EnigmaMachine
# machine = EnigmaMachine.from_config({
# 'rotors': ['I', 'II', 'III'],
# 'reflector': 'B',
# 'ring_settings': 'AAA',
# 'initial_positions': 'AAA',
# 'plugboard_pairs': ['AB', 'CD'],
# })
# ciphertext = machine.encrypt_string('HELLO')
# ---------------------------------------------------------------------------
# Historical rotor and reflector wiring tables.
# Each entry is (wiring_string, notch_letter).
# The wiring string maps input index i to output letter wiring[i].
# The notch letter is the position at which this rotor causes the
# rotor to its left to step on the next keypress.
# ---------------------------------------------------------------------------
ROTOR_WIRINGS = {
'I': ('EKMFLGDQVZNTOWYHXUSPAIBRCJ', 'Y'),
'II': ('AJDKSIRUXBLHWTMCQGZNPYFVOE', 'M'),
'III': ('BDFHJLCPRTXVZNYEIWGAKMUSQO', 'V'),
'IV': ('ESOVPZJAYQUIRHXLNFTGKDCMWB', 'J'),
'V': ('VZBRGITYUPSDNHLXAWMJQOFECK', 'Z'),
}
REFLECTOR_WIRINGS = {
'B': 'YRUHQSLDPXNGOKMIEBFZCWVJAT',
'C': 'FVPJIAOYEDRZXWGCTKUQSBNMHL',
}
ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
def letter_to_index(letter):
"""
Convert an uppercase letter to its zero-based index (A=0, Z=25).
Args:
letter: A single uppercase letter character.
Returns:
An integer in the range [0, 25].
"""
return ord(letter.upper()) - ord('A')
def index_to_letter(index):
"""
Convert a zero-based index to its corresponding uppercase letter.
Args:
index: An integer; values outside [0, 25] are wrapped via modulo 26.
Returns:
A single uppercase letter character.
"""
return chr((index % 26) + ord('A'))
# ---------------------------------------------------------------------------
# Plugboard
# ---------------------------------------------------------------------------
class Plugboard:
"""
Models the Enigma plugboard (Steckerbrett).
The plugboard swaps pairs of letters before the signal enters the
rotor assembly and after it exits. It is its own inverse: applying
it twice returns the original letter. Internally it is stored as a
list of 26 integers where plugboard[i] gives the output index for
input index i. Unplugged letters map to themselves.
"""
def __init__(self, pairs=None):
"""
Initialize the plugboard with a list of letter pairs.
Args:
pairs: A list of two-letter strings, e.g. ['AB', 'CD', 'EF'].
Each string represents a cable connecting two letters.
Maximum 13 pairs (26 letters divided by 2).
Pass None or an empty list for no cables.
Raises:
ValueError: If a letter appears in more than one pair, if a
pair does not contain exactly two distinct letters,
or if more than 13 pairs are supplied.
"""
# Start with identity mapping: each letter maps to itself.
self._mapping = list(range(26))
if pairs:
self._apply_pairs(pairs)
def _apply_pairs(self, pairs):
"""
Apply the cable connections to the internal mapping.
Validates each pair and builds the bidirectional swap mapping.
Each cable creates two entries: A->B and B->A.
Args:
pairs: The list of two-letter pair strings to apply.
Raises:
ValueError: On any invalid pair configuration.
"""
if len(pairs) > 13:
raise ValueError(
f"Maximum 13 plugboard pairs allowed, got {len(pairs)}"
)
used_letters = set()
for pair in pairs:
if len(pair) != 2:
raise ValueError(
f"Each plugboard pair must be exactly 2 letters, "
f"got '{pair}' with length {len(pair)}"
)
a, b = pair[0].upper(), pair[1].upper()
if a == b:
raise ValueError(
f"A plugboard pair cannot connect a letter to itself: '{pair}'"
)
if a not in ALPHABET or b not in ALPHABET:
raise ValueError(
f"Plugboard pair '{pair}' contains a non-letter character"
)
for letter in (a, b):
if letter in used_letters:
raise ValueError(
f"Letter '{letter}' appears in more than one plugboard pair"
)
used_letters.add(letter)
idx_a = letter_to_index(a)
idx_b = letter_to_index(b)
# Create the bidirectional swap.
self._mapping[idx_a] = idx_b
self._mapping[idx_b] = idx_a
def forward(self, index):
"""
Pass a signal through the plugboard.
Since the plugboard is symmetric (it just swaps pairs), the
forward and backward directions are identical.
Args:
index: An integer 0-25 representing the input letter.
Returns:
An integer 0-25 representing the output letter.
"""
return self._mapping[index]
# The plugboard is its own inverse, so backward == forward.
backward = forward
def get_pairs(self):
"""
Return the current cable connections as a list of letter pairs.
Returns:
A sorted list of strings like ['AB', 'CD'], one per cable.
The list is sorted alphabetically for deterministic output.
"""
pairs = []
seen = set()
for i, j in enumerate(self._mapping):
if i != j and i not in seen:
pairs.append(index_to_letter(i) + index_to_letter(j))
seen.add(i)
seen.add(j)
return sorted(pairs)
def __repr__(self):
return f"Plugboard(pairs={self.get_pairs()})"
# ---------------------------------------------------------------------------
# Rotor
# ---------------------------------------------------------------------------
class Rotor:
"""
Models a single Enigma rotor (Walze).
A rotor implements a permutation of the 26-letter alphabet. The
permutation is defined by the physical wiring inside the rotor.
The rotor can be set to any of 26 positions (offset), and the
internal wiring can be shifted relative to the outer ring
(ring setting).
The key mathematical operation for the forward direction is:
adjusted_input = (index + offset - ring) % 26
wired_output = forward_wiring[adjusted_input]
adjusted_output = (wired_output - offset + ring) % 26
The backward direction uses the precomputed inverse wiring table
with the same offset and ring adjustments.
"""
def __init__(self, name, wiring, notch, ring_setting=0, offset=0):
"""
Initialize a rotor with its wiring and settings.
Args:
name: The rotor's identifier (e.g., 'I', 'II', 'III').
wiring: A 26-character string defining the permutation.
The i-th character is the output for input i.
notch: The letter at which this rotor causes the next
rotor to step (e.g., 'Y' for Rotor I).
ring_setting: Integer 0-25 (A=0) for the Ringstellung.
Shifts the wiring relative to the outer ring.
offset: Integer 0-25 for the current rotor position.
This changes with each keypress.
"""
self.name = name
self._notch = letter_to_index(notch)
self._ring_setting = ring_setting % 26
self._offset = offset % 26
# Build the forward wiring table: forward_wiring[i] = output for input i.
self._forward_wiring = [letter_to_index(c) for c in wiring]
# Build the backward (inverse) wiring table.
# If forward_wiring[i] = j, then backward_wiring[j] = i.
self._backward_wiring = [0] * 26
for i, j in enumerate(self._forward_wiring):
self._backward_wiring[j] = i
@property
def offset(self):
"""The current rotational position of the rotor (0-25)."""
return self._offset
@offset.setter
def offset(self, value):
"""Set the rotor position, wrapping around modulo 26."""
self._offset = value % 26
@property
def ring_setting(self):
"""The ring setting (Ringstellung) of this rotor (0-25)."""
return self._ring_setting
@ring_setting.setter
def ring_setting(self, value):
"""Set the ring setting, wrapping around modulo 26."""
self._ring_setting = value % 26
@property
def display_letter(self):
"""The letter currently visible in the rotor window."""
return index_to_letter(self._offset)
def is_at_notch(self):
"""
Check whether the rotor is currently at its notch position.
When a rotor is at its notch position, pressing a key will cause
the rotor to the left of it to step forward. For the middle rotor,
being at its notch also triggers the double-stepping anomaly.
Returns:
True if the rotor is currently at its notch position.
"""
return self._offset == self._notch
def step(self):
"""
Advance the rotor by one position.
Simulates the mechanical rotation of the rotor. The offset
wraps around from Z (25) back to A (0).
"""
self._offset = (self._offset + 1) % 26
def forward(self, index):
"""
Pass a signal through the rotor in the forward direction
(right to left, from keyboard toward the reflector).
The signal enters at the rotor's right contact (adjusted for
offset and ring setting), passes through the internal wiring,
and exits at the left contact (adjusted back to the fixed frame).
Args:
index: Integer 0-25, the incoming signal position.
Returns:
Integer 0-25, the outgoing signal position.
"""
adjusted_input = (index + self._offset - self._ring_setting) % 26
wired_output = self._forward_wiring[adjusted_input]
adjusted_output = (wired_output - self._offset + self._ring_setting) % 26
return adjusted_output
def backward(self, index):
"""
Pass a signal through the rotor in the backward direction
(left to right, returning from the reflector toward the lampboard).
Uses the precomputed inverse wiring table. The offset and ring
setting adjustments are identical to the forward direction.
Args:
index: Integer 0-25, the incoming signal position.
Returns:
Integer 0-25, the outgoing signal position.
"""
adjusted_input = (index + self._offset - self._ring_setting) % 26
wired_output = self._backward_wiring[adjusted_input]
adjusted_output = (wired_output - self._offset + self._ring_setting) % 26
return adjusted_output
def __repr__(self):
return (
f"Rotor({self.name}, "
f"offset={self.display_letter}, "
f"ring={index_to_letter(self._ring_setting)})"
)
# ---------------------------------------------------------------------------
# Reflector
# ---------------------------------------------------------------------------
class Reflector:
"""
Models the Enigma reflector (Umkehrwalze, UKW).
The reflector is a fixed permutation that maps each letter to another
letter, with the critical property that it is an involution: if it
maps A to R, it also maps R to A. This means the reflector is its own
inverse, which is what allows the same Enigma settings to both encrypt
and decrypt. The reflector never rotates, so there is no offset or
ring setting.
"""
def __init__(self, name, wiring):
"""
Initialize the reflector with its wiring.
Args:
name: The reflector's identifier (e.g., 'B', 'C').
wiring: A 26-character string defining the permutation.
Must be a valid involution (self-inverse permutation)
with no fixed points (no letter mapping to itself).
Raises:
ValueError: If the wiring is not a valid involution, or if
any letter maps to itself.
"""
self.name = name
self._wiring = [letter_to_index(c) for c in wiring]
self._validate_involution()
def _validate_involution(self):
"""
Verify that the reflector wiring is a valid involution.
An involution must satisfy wiring[wiring[i]] == i for all i.
Also, no letter may map to itself, which would create a short
circuit in the real machine and is historically forbidden.
Raises:
ValueError: If any validation check fails.
"""
for i in range(26):
j = self._wiring[i]
if i == j:
raise ValueError(
f"Reflector wiring maps letter {index_to_letter(i)} "
f"to itself, which is not allowed."
)
if self._wiring[j] != i:
raise ValueError(
f"Reflector wiring is not an involution: "
f"{index_to_letter(i)} -> {index_to_letter(j)} but "
f"{index_to_letter(j)} -> "
f"{index_to_letter(self._wiring[j])}"
)
def reflect(self, index):
"""
Pass a signal through the reflector.
Args:
index: Integer 0-25, the incoming signal position.
Returns:
Integer 0-25, the reflected signal position.
"""
return self._wiring[index]
def __repr__(self):
return f"Reflector({self.name})"
# ---------------------------------------------------------------------------
# EnigmaMachine
# ---------------------------------------------------------------------------
class EnigmaMachine:
"""
The complete Enigma machine simulation.
Combines the plugboard, three rotors, and a reflector into a
functioning cipher machine. Implements the full signal path and
the rotor stepping mechanism, including the historically accurate
double-stepping anomaly.
The signal path for each keypress is:
keyboard -> plugboard -> right rotor (forward) ->
middle rotor (forward) -> left rotor (forward) ->
reflector -> left rotor (backward) ->
middle rotor (backward) -> right rotor (backward) ->
plugboard -> lampboard
Rotors in the internal list are ordered [left, middle, right],
i.e., index 0 is the leftmost rotor as seen by the operator.
"""
def __init__(self, rotors, reflector, plugboard):
"""
Initialize the Enigma machine with its components.
Args:
rotors: A list of exactly three Rotor objects, ordered
[left, middle, right] as they appear in the machine.
reflector: A Reflector object.
plugboard: A Plugboard object.
Raises:
ValueError: If the number of rotors is not exactly three.
"""
if len(rotors) != 3:
raise ValueError(
f"Enigma requires exactly 3 rotors, got {len(rotors)}"
)
self._rotors = rotors
self._reflector = reflector
self._plugboard = plugboard
# Snapshot the initial offsets so reset() can restore them.
self._initial_offsets = [r.offset for r in rotors]
@classmethod
def from_config(cls, config):
"""
Factory method to create an EnigmaMachine from a configuration dict.
This is the primary way to create an EnigmaMachine, as it handles
all the parsing and validation of the configuration parameters.
Args:
config: A dictionary with the following keys:
'rotors' - List of 3 rotor names, e.g. ['I','II','III'].
Index 0 is the left (slow) rotor.
'reflector' - Reflector name, e.g. 'B'.
'ring_settings' - 3-letter string, e.g. 'AAA' or 'MCK'.
'initial_positions' - 3-letter string, e.g. 'AAA' or 'XYZ'.
'plugboard_pairs' - List of pair strings, e.g. ['AB', 'CD'].
Returns:
A fully configured EnigmaMachine instance.
Raises:
ValueError: If any configuration value is invalid.
"""
rotor_names = config.get('rotors', ['I', 'II', 'III'])
reflector_name = config.get('reflector', 'B')
ring_settings = config.get('ring_settings', 'AAA')
initial_positions = config.get('initial_positions', 'AAA')
plugboard_pairs = config.get('plugboard_pairs', [])
if len(rotor_names) != 3:
raise ValueError(
f"Exactly 3 rotor names required, got {len(rotor_names)}"
)
rotors = []
for i, name in enumerate(rotor_names):
if name not in ROTOR_WIRINGS:
raise ValueError(f"Unknown rotor: '{name}'")
wiring, notch = ROTOR_WIRINGS[name]
ring = letter_to_index(ring_settings[i].upper())
offset = letter_to_index(initial_positions[i].upper())
rotors.append(Rotor(name, wiring, notch, ring, offset))
if reflector_name not in REFLECTOR_WIRINGS:
raise ValueError(f"Unknown reflector: '{reflector_name}'")
reflector = Reflector(reflector_name, REFLECTOR_WIRINGS[reflector_name])
plugboard = Plugboard(plugboard_pairs if plugboard_pairs else None)
return cls(rotors, reflector, plugboard)
def _step_rotors(self):
"""
Advance the rotors according to the Enigma stepping mechanism.
Implements the historically accurate stepping behavior, including
the double-stepping anomaly of the middle rotor.
The stepping rules, evaluated against positions BEFORE any stepping:
Rule 1 (Double-step): If the middle rotor is at its notch, both
the middle rotor AND the left rotor step. This is the anomaly —
the middle rotor steps even though it is not being driven by the
right rotor's notch in this case.
Rule 2 (Normal carry): If the right rotor is at its notch AND the
middle rotor is NOT at its notch, the middle rotor steps. This is
the normal odometer-style carry.
Rule 3 (Always): The right rotor always steps on every keypress.
The order of evaluation is critical: notch positions must be
checked BEFORE any stepping occurs.
"""
left, middle, right = self._rotors
# Capture notch states before any stepping.
middle_at_notch = middle.is_at_notch()
right_at_notch = right.is_at_notch()
# Rule 1: Double-stepping anomaly.
if middle_at_notch:
middle.step()
left.step()
# Rule 2: Normal carry from right to middle.
elif right_at_notch:
middle.step()
# Rule 3: Right rotor always steps.
right.step()
def encrypt_letter(self, letter):
"""
Encrypt a single letter through the Enigma machine.
This is the fundamental operation of the machine. It first steps
the rotors (changing the machine state), then passes the signal
through the complete signal path.
Args:
letter: A single letter (A-Z, case insensitive).
Non-letter characters are returned unchanged.
Returns:
The encrypted letter as an uppercase string, or the original
character unchanged if it is not a letter.
"""
letter = letter.upper()
if letter not in ALPHABET:
return letter
# Step 1: Advance the rotors BEFORE encrypting.
self._step_rotors()
# Step 2: Convert letter to index.
signal = letter_to_index(letter)
# Step 3: Pass through plugboard (forward).
signal = self._plugboard.forward(signal)
# Step 4: Pass through rotors right to left (forward direction).
signal = self._rotors[2].forward(signal) # Right rotor
signal = self._rotors[1].forward(signal) # Middle rotor
signal = self._rotors[0].forward(signal) # Left rotor
# Step 5: Pass through reflector.
signal = self._reflector.reflect(signal)
# Step 6: Pass back through rotors left to right (backward direction).
signal = self._rotors[0].backward(signal) # Left rotor
signal = self._rotors[1].backward(signal) # Middle rotor
signal = self._rotors[2].backward(signal) # Right rotor
# Step 7: Pass through plugboard again (backward == forward for plugboard).
signal = self._plugboard.backward(signal)
# Step 8: Convert back to letter.
return index_to_letter(signal)
def encrypt_string(self, text):
"""
Encrypt an entire string of text.
Each letter is encrypted in sequence, with the machine state
advancing after each letter. Non-letter characters (spaces,
punctuation, digits) are passed through unchanged.
Args:
text: The plaintext or ciphertext string to process.
Returns:
The encrypted or decrypted string.
"""
return ''.join(self.encrypt_letter(c) for c in text)
def get_state(self):
"""
Return the current machine state as a serializable dictionary.
Used by the networking layer to share machine configuration
between two connected Enigma applications so that operators
can verify their settings match.
Returns:
A dictionary containing all state information needed to
reconstruct the current machine configuration exactly.
"""
return {
'rotors': [r.name for r in self._rotors],
'reflector': self._reflector.name,
'ring_settings': ''.join(
index_to_letter(r.ring_setting) for r in self._rotors
),
'current_positions': ''.join(
r.display_letter for r in self._rotors
),
'plugboard_pairs': self._plugboard.get_pairs(),
}
def reset(self):
"""
Reset the rotor positions to their initial starting positions.
Does not change the ring settings, rotor selection, or plugboard
configuration; only the rotor offsets are restored. Used when
starting a new message with the same daily settings.
"""
for rotor, initial_offset in zip(self._rotors, self._initial_offsets):
rotor.offset = initial_offset
@property
def rotor_display(self):
"""
Return the three letters currently visible in the rotor windows.
Returns:
A string of three letters, e.g. 'MCK', representing the
current positions of the left, middle, and right rotors.
"""
return ''.join(r.display_letter for r in self._rotors)
def __repr__(self):
return (
f"EnigmaMachine("
f"rotors={[r.name for r in self._rotors]}, "
f"reflector={self._reflector.name}, "
f"position={self.rotor_display})"
)
# ---------------------------------------------------------------------------
# Self-tests
# ---------------------------------------------------------------------------
def run_self_test():
"""
Verify the cryptographic engine against known test vectors.
Test 1 checks absolute output against a well-established vector.
Test 2 verifies the involution property (decrypt(encrypt(x)) == x).
Test 3 verifies that no letter ever encrypts to itself.
Test 4 verifies the double-stepping anomaly.
Raises:
AssertionError: If any test fails.
"""
print("Running Enigma cryptographic self-tests...")
# ------------------------------------------------------------------
# Test 1: Known output for rotors I, II, III at position AAA.
# Encrypting 'AAAAA' must produce 'BDZGO'.
# This vector is widely documented and has been verified against
# physical Enigma machines and multiple independent simulators.
# ------------------------------------------------------------------
machine = EnigmaMachine.from_config({
'rotors': ['I', 'II', 'III'],
'reflector': 'B',
'ring_settings': 'AAA',
'initial_positions': 'AAA',
'plugboard_pairs': [],
})
result = machine.encrypt_string('AAAAA')
assert result == 'BDZGO', (
f"Test 1 FAILED: expected 'BDZGO', got '{result}'"
)
print(f" Test 1 PASSED: 'AAAAA' -> '{result}'")
# ------------------------------------------------------------------
# Test 2: Involution property.
# Encrypting the ciphertext with the same initial settings must
# recover the original plaintext.
# ------------------------------------------------------------------
machine2 = EnigmaMachine.from_config({
'rotors': ['I', 'II', 'III'],
'reflector': 'B',
'ring_settings': 'AAA',
'initial_positions': 'AAA',
'plugboard_pairs': [],
})
decrypted = machine2.encrypt_string(result)
assert decrypted == 'AAAAA', (
f"Test 2 FAILED: decryption gave '{decrypted}', expected 'AAAAA'"
)
print(f" Test 2 PASSED: '{result}' -> '{decrypted}' (involution confirmed)")
# ------------------------------------------------------------------
# Test 3: No letter encrypts to itself.
# This is a fundamental property of the Enigma due to the reflector.
# ------------------------------------------------------------------
machine3 = EnigmaMachine.from_config({
'rotors': ['I', 'II', 'III'],
'reflector': 'B',
'ring_settings': 'AAA',
'initial_positions': 'AAA',
'plugboard_pairs': [],
})
plaintext = ALPHABET * 10 # 260 letters
ciphertext = machine3.encrypt_string(plaintext)
for p, c in zip(plaintext, ciphertext):
assert p != c, (
f"Test 3 FAILED: letter '{p}' encrypted to itself"
)
print(" Test 3 PASSED: No letter encrypts to itself over 260 characters.")
# ------------------------------------------------------------------
# Test 4: Double-stepping anomaly.
# Rotors I, II, III. Rotor II notch at M (index 12).
# Rotor III notch at V (index 21).
# Starting at KMV (middle at notch M, right at notch V):
#
# Press 1: middle_at_notch=True -> left steps (K->L), middle steps
# (M->N) via double-step; right_at_notch=True but the elif
# branch is skipped because the if branch already fired;
# right always steps (V->W). Result: LNW.
#
# Press 2: middle(N) not at notch, right(W) not at notch.
# Only right steps (W->X). Result: LNX.
# ------------------------------------------------------------------
machine4 = EnigmaMachine.from_config({
'rotors': ['I', 'II', 'III'],
'reflector': 'B',
'ring_settings': 'AAA',
'initial_positions': 'KMV',
'plugboard_pairs': [],
})
assert machine4.rotor_display == 'KMV', (
f"Test 4 setup FAILED: expected 'KMV', got '{machine4.rotor_display}'"
)
machine4.encrypt_letter('A')
assert machine4.rotor_display == 'LNW', (
f"Test 4a FAILED: after press 1 expected 'LNW', "
f"got '{machine4.rotor_display}' — double-stepping anomaly incorrect"
)
machine4.encrypt_letter('A')
assert machine4.rotor_display == 'LNX', (
f"Test 4b FAILED: after press 2 expected 'LNX', "
f"got '{machine4.rotor_display}'"
)
print(" Test 4 PASSED: KMV -> LNW -> LNX (double-stepping anomaly correct)")
print("\nAll cryptographic engine tests PASSED.")
if __name__ == '__main__':
run_self_test()
The forward and backward methods are the heart of the rotor simulation. The three-step calculation — adjust for offset and ring, apply wiring, adjust back — is the mathematical formula we derived in Part Two, now expressed in Python. The modulo 26 arithmetic ensures that all values wrap around correctly at the boundaries of the alphabet. We build both the forward wiring table and its inverse in the constructor. Since the Enigma encrypts one character at a time and the tables are only 26 elements long, this is a trivial computation, but precomputing the inverse avoids a linear search through the forward table on every backward pass.
The double-stepping test deserves careful attention. Starting at position KMV, the middle rotor (Rotor II) is at its notch M and the right rotor (Rotor III) is at its notch V. In _step_rotors(), we first check middle_at_notch, which is True. So we step both the middle rotor (M to N) and the left rotor (K to L). Because the if branch executed, the elif branch is skipped, even though right_at_notch is also True. The right rotor always steps unconditionally, so V advances to W. The result is LNW. On the second keypress, neither N nor W is a notch position, so only the right rotor steps, giving LNX. This sequence is the correct historical behavior.
CHAPTER FIVE: BUILDING THE GRAPHICAL INTERFACE
5.1 Design Philosophy for the GUI
The graphical interface presents one of the most interesting design challenges in this project. We want the application to look and feel like a real Enigma machine, not just a generic text encryption tool. This means we need to render the physical components of the machine faithfully: the keyboard with its distinctive QWERTZ layout, the lampboard above the keyboard, the rotor windows showing the current positions, and the plugboard at the bottom.
We use Tkinter as our GUI framework because it is included with every standard Python installation and requires no additional installation. The Canvas widget is the workhorse of our graphical implementation; it allows us to draw arbitrary shapes, text, and images, and to animate them by updating their properties over time using Tkinter's after() scheduling mechanism.
The animation system is built around Tkinter's after() method, which schedules a function to be called after a specified delay in milliseconds. By scheduling a sequence of state changes with short delays between them, we create smooth animations of keys pressing and releasing, lamps illuminating and fading, and rotors advancing. All animation callbacks run on the main GUI thread, which is the only thread allowed to modify Tkinter widgets.
5.2 The Complete GUI File
The complete enigma_gui.py file is presented below. It is a single self-contained module that imports only from enigma_engine and from the Python standard library's tkinter package. All methods, including _open_network_dialogand _receive_character, are fully integrated into the EnigmaGUI class.
# enigma_gui.py
#
# The graphical user interface for the Enigma machine simulation.
# Renders a faithful visual representation of the Enigma machine
# using Tkinter's Canvas widget, with animations for key presses,
# lamp illumination, and rotor stepping.
#
# This module depends on enigma_engine.py for cryptographic operations
# but has no dependency on the networking layer. Network integration
# is achieved through callbacks and the network_client / _enigma_server
# attributes set at runtime.
import math
import threading
import tkinter as tk
from tkinter import ttk, messagebox
from enigma_engine import (
EnigmaMachine,
Plugboard,
ALPHABET,
ROTOR_WIRINGS,
REFLECTOR_WIRINGS,
)
# ---------------------------------------------------------------------------
# Visual constants — all measurements in pixels
# ---------------------------------------------------------------------------
COLOR_MACHINE_BODY = '#2C1810' # Dark mahogany brown
COLOR_PANEL = '#1A0F0A' # Very dark brown for panels
COLOR_KEY_IDLE = '#F5F0E8' # Ivory for unpressed keys
COLOR_KEY_PRESSED = '#C8B89A' # Darker ivory for pressed keys
COLOR_KEY_TEXT = '#1A0F0A' # Dark text on keys
COLOR_LAMP_OFF = '#3D2B1F' # Dark amber for unlit lamps
COLOR_LAMP_ON = '#FFD700' # Bright gold for lit lamps
COLOR_LAMP_GLOW = '#FFA500' # Orange glow around lit lamps
COLOR_ROTOR_BODY = '#4A3728' # Medium brown for rotor housing
COLOR_ROTOR_WINDOW = '#000000' # Black background for rotor windows
COLOR_ROTOR_TEXT = '#FFD700' # Gold text in rotor windows
COLOR_PLUGBOARD_BODY = '#3D2B1F' # Dark panel for plugboard
COLOR_BACKGROUND = '#1A0F0A' # Window background
# Colors for plugboard cable visualization (one per cable, up to 13)
COLOR_WIRE_COLORS = [
'#FF4444', '#44FF44', '#4444FF', '#FFFF44', '#FF44FF',
'#44FFFF', '#FF8844', '#8844FF', '#44FF88', '#FF4488',
'#88FF44', '#4488FF', '#FF8888',
]
# The historical Enigma keyboard used a QWERTZ layout.
# Row 0 is the top row, Row 1 is the middle, Row 2 is the bottom.
KEYBOARD_ROWS = [
'QWERTZUIO', # Top row (9 keys)
'ASDFGHJK', # Middle row (8 keys)
'PYXCVBNML', # Bottom row (9 keys)
]
# The lampboard has the same letter layout as the keyboard.
LAMPBOARD_ROWS = KEYBOARD_ROWS
# Key and lamp dimensions
KEY_WIDTH = 44
KEY_HEIGHT = 44
KEY_RADIUS = 8 # Corner radius for rounded rectangle keys
KEY_SPACING = 52 # Center-to-center spacing between adjacent keys
LAMP_RADIUS = 18 # Radius of each lamp circle
# Animation timing in milliseconds
ANIM_KEY_PRESS_DURATION = 80 # How long a key stays visually pressed
ANIM_LAMP_ON_DURATION = 300 # How long a lamp stays fully lit
ANIM_LAMP_FADE_STEPS = 10 # Number of interpolation steps in lamp fade
ANIM_ROTOR_FLASH_HALF = 75 # Half-duration of rotor step flash
# ---------------------------------------------------------------------------
# Helper: rounded rectangle
# ---------------------------------------------------------------------------
def create_rounded_rectangle(canvas, x1, y1, x2, y2, radius=10, **kwargs):
"""
Draw a rounded rectangle on a Tkinter Canvas.
Tkinter's Canvas does not have a built-in rounded rectangle primitive,
so we construct one from a polygon whose points trace the outline of
a rectangle with circular arcs at each corner.
Args:
canvas: The Tkinter Canvas widget to draw on.
x1, y1: Top-left corner coordinates.
x2, y2: Bottom-right corner coordinates.
radius: The corner radius in pixels.
**kwargs: Additional keyword arguments passed to canvas.create_polygon
(e.g., fill, outline, width, smooth).
Returns:
The canvas item ID of the created polygon.
"""
points = []
# Top-left corner arc (180 to 270 degrees)
for angle in range(180, 271, 10):
rad = math.radians(angle)
points.extend([
x1 + radius + radius * math.cos(rad),
y1 + radius + radius * math.sin(rad),
])
# Top-right corner arc (270 to 360 degrees)
for angle in range(270, 361, 10):
rad = math.radians(angle)
points.extend([
x2 - radius + radius * math.cos(rad),
y1 + radius + radius * math.sin(rad),
])
# Bottom-right corner arc (0 to 90 degrees)
for angle in range(0, 91, 10):
rad = math.radians(angle)
points.extend([
x2 - radius + radius * math.cos(rad),
y2 - radius + radius * math.sin(rad),
])
# Bottom-left corner arc (90 to 180 degrees)
for angle in range(90, 181, 10):
rad = math.radians(angle)
points.extend([
x1 + radius + radius * math.cos(rad),
y2 - radius + radius * math.sin(rad),
])
return canvas.create_polygon(points, smooth=True, **kwargs)
# ---------------------------------------------------------------------------
# EnigmaGUI
# ---------------------------------------------------------------------------
class EnigmaGUI:
"""
The main graphical user interface for the Enigma machine simulation.
Creates and manages the complete visual representation of the Enigma
machine, including the rotor display windows, lampboard, keyboard,
plugboard visualization, message log, and network controls.
The GUI communicates with the EnigmaMachine engine through a clean
interface: it calls engine methods for cryptographic operations and
reads engine state for display updates.
Network integration works as follows. When operating as a server,
self._enigma_server is set to an EnigmaServer instance; typed characters
are placed in the server's outgoing queue via _enigma_server.send_character()
and received characters arrive via the server's character callback. When
operating as a client, self.network_client is set to an EnigmaClient
instance; typed characters are sent via network_client.send_character()
and received characters arrive via the client's polling loop callback.
"""
def __init__(self, root):
"""
Initialize the GUI and create all visual components.
Args:
root: The Tkinter root window (Tk instance).
"""
self.root = root
self.root.title("Enigma Machine Simulator — Wehrmacht Model")
self.root.configure(bg=COLOR_BACKGROUND)
self.root.resizable(False, False)
# The cryptographic engine, initialized with default settings.
self.machine = EnigmaMachine.from_config({
'rotors': ['I', 'II', 'III'],
'reflector': 'B',
'ring_settings': 'AAA',
'initial_positions': 'AAA',
'plugboard_pairs': [],
})
# Animation state: tracks pending after() IDs to allow cancellation.
self._pressed_keys = {} # letter -> after_id for key release
self._lit_lamps = {} # letter -> after_id for lamp fade start
self._lamp_fade_ids = {} # letter -> list of after_ids for fade steps
# Canvas item IDs for interactive elements.
self._key_items = {} # letter -> (bg_id, text_id)
self._lamp_items = {} # letter -> (circle_id, text_id, glow_id)
self._rotor_text_ids = [] # [left_id, middle_id, right_id]
self._rotor_canvases = [] # [left_canvas, middle_canvas, right_canvas]
self._plug_wire_items = {} # pair_string -> list of canvas item IDs
# Message log state.
self._input_text = [] # Characters typed by the user
self._output_text = [] # Encrypted output characters
# Network state.
# network_client is set when this instance operates as a REST client.
# _enigma_server is set when this instance operates as a REST server.
self.network_client = None # EnigmaClient instance (client mode)
self._enigma_server = None # EnigmaServer instance (server mode)
self._peer_connected = False # True when a peer is actively connected
# Default server port (may be overridden by main.py via --port).
self._default_server_port = 5000
# Build the complete interface.
self._build_interface()
# Bind physical keyboard input to the root window.
self.root.bind('<Key>', self._on_keyboard_input)
self.root.focus_set()
# -----------------------------------------------------------------------
# Interface construction
# -----------------------------------------------------------------------
def _build_interface(self):
"""
Construct all visual components of the Enigma machine interface.
The layout is organized vertically from top to bottom:
the title bar, rotor assembly display, lampboard, keyboard,
plugboard visualization, message log, and control panel.
"""
main_frame = tk.Frame(
self.root,
bg=COLOR_MACHINE_BODY,
relief=tk.RAISED,
borderwidth=4,
)
main_frame.pack(padx=10, pady=10)
self._build_title_bar(main_frame)
self._build_rotor_display(main_frame)
self._build_lampboard(main_frame)
self._build_keyboard(main_frame)
self._build_plugboard_display(main_frame)
self._build_message_panel(main_frame)
self._build_control_panel(main_frame)
def _build_title_bar(self, parent):
"""
Create the title bar with machine identification text.
The title bar mimics the nameplate that would appear on a real
Enigma machine, identifying the model and its purpose.
"""
title_frame = tk.Frame(parent, bg=COLOR_MACHINE_BODY)
title_frame.pack(fill=tk.X, padx=20, pady=(15, 5))
tk.Label(
title_frame,
text="ENIGMA MASCHINE",
font=('Courier', 18, 'bold'),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
).pack()
tk.Label(
title_frame,
text="Wehrmacht Model | Heeresverschluesselgeraet",
font=('Courier', 9),
fg='#8B7355',
bg=COLOR_MACHINE_BODY,
).pack()
def _build_rotor_display(self, parent):
"""
Create the rotor assembly display showing three rotor windows.
Each rotor window shows the current letter visible through the
small window on top of the real machine. The windows animate
to show the letter changing when a rotor steps. Up and down
buttons allow the operator to manually set each rotor position,
exactly as operators did on the physical machine before starting
a message.
"""
rotor_frame = tk.Frame(
parent,
bg=COLOR_ROTOR_BODY,
relief=tk.SUNKEN,
borderwidth=3,
)
rotor_frame.pack(padx=20, pady=10, fill=tk.X)
tk.Label(
rotor_frame,
text="ROTOR POSITIONS",
font=('Courier', 8),
fg='#8B7355',
bg=COLOR_ROTOR_BODY,
).pack(pady=(5, 0))
windows_frame = tk.Frame(rotor_frame, bg=COLOR_ROTOR_BODY)
windows_frame.pack(pady=10)
rotor_labels = ['LEFT', 'MIDDLE', 'RIGHT']
for i, label in enumerate(rotor_labels):
rotor_col = tk.Frame(windows_frame, bg=COLOR_ROTOR_BODY)
rotor_col.pack(side=tk.LEFT, padx=20)
tk.Label(
rotor_col,
text=label,
font=('Courier', 7),
fg='#8B7355',
bg=COLOR_ROTOR_BODY,
).pack()
# Up button: advance this rotor by one position.
tk.Button(
rotor_col,
text='^',
font=('Courier', 10, 'bold'),
fg='#C8A96E',
bg=COLOR_PANEL,
relief=tk.FLAT,
command=lambda idx=i: self._manual_rotor_advance(idx),
width=3,
).pack()
# The rotor window canvas.
rotor_canvas = tk.Canvas(
rotor_col,
width=60,
height=60,
bg=COLOR_ROTOR_WINDOW,
highlightthickness=2,
highlightbackground='#C8A96E',
)
rotor_canvas.pack()
self._rotor_canvases.append(rotor_canvas)
# The letter displayed in the window.
text_id = rotor_canvas.create_text(
30, 30,
text=self.machine.rotor_display[i],
font=('Courier', 28, 'bold'),
fill=COLOR_ROTOR_TEXT,
)
self._rotor_text_ids.append(text_id)
# Down button: retract this rotor by one position.
tk.Button(
rotor_col,
text='v',
font=('Courier', 10, 'bold'),
fg='#C8A96E',
bg=COLOR_PANEL,
relief=tk.FLAT,
command=lambda idx=i: self._manual_rotor_retract(idx),
width=3,
).pack()
def _build_lampboard(self, parent):
"""
Create the lampboard — the output display of the Enigma machine.
The lampboard has the same letter layout as the keyboard. When a
key is pressed and the machine encrypts it, the corresponding
output letter's lamp illuminates briefly with a gold glow, then
fades back to dark amber through a smooth color interpolation.
"""
lamp_frame = tk.Frame(
parent,
bg=COLOR_PANEL,
relief=tk.SUNKEN,
borderwidth=2,
)
lamp_frame.pack(padx=20, pady=5, fill=tk.X)
tk.Label(
lamp_frame,
text="LAMPBOARD",
font=('Courier', 8),
fg='#8B7355',
bg=COLOR_PANEL,
).pack(pady=(5, 0))
canvas_width = 10 * KEY_SPACING + 40
canvas_height = 3 * KEY_SPACING + 20
self._lamp_canvas = tk.Canvas(
lamp_frame,
width=canvas_width,
height=canvas_height,
bg=COLOR_PANEL,
highlightthickness=0,
)
self._lamp_canvas.pack(pady=5)
for row_idx, row in enumerate(LAMPBOARD_ROWS):
row_width = len(row) * KEY_SPACING
x_start = (canvas_width - row_width) // 2 + KEY_SPACING // 2
y = 30 + row_idx * KEY_SPACING
for col_idx, letter in enumerate(row):
x = x_start + col_idx * KEY_SPACING
# Outer glow circle (same color as panel when unlit).
glow_id = self._lamp_canvas.create_oval(
x - LAMP_RADIUS - 6, y - LAMP_RADIUS - 6,
x + LAMP_RADIUS + 6, y + LAMP_RADIUS + 6,
fill=COLOR_PANEL,
outline='',
)
# Main lamp circle.
lamp_id = self._lamp_canvas.create_oval(
x - LAMP_RADIUS, y - LAMP_RADIUS,
x + LAMP_RADIUS, y + LAMP_RADIUS,
fill=COLOR_LAMP_OFF,
outline='#5A3D2B',
width=2,
)
# Letter label on the lamp.
text_id = self._lamp_canvas.create_text(
x, y,
text=letter,
font=('Courier', 11, 'bold'),
fill='#8B7355',
)
self._lamp_items[letter] = (lamp_id, text_id, glow_id)
def _build_keyboard(self, parent):
"""
Create the keyboard — the input device of the Enigma machine.
The keyboard layout matches the historical Enigma keyboard, which
used a QWERTZ layout common in German typewriters rather than the
QWERTY layout used in English-speaking countries. Each key is drawn
as a rounded rectangle on a Canvas. When clicked or when the
corresponding physical keyboard key is pressed, the key visually
depresses and then springs back up after a short delay.
"""
keyboard_frame = tk.Frame(
parent,
bg=COLOR_PANEL,
relief=tk.SUNKEN,
borderwidth=2,
)
keyboard_frame.pack(padx=20, pady=5, fill=tk.X)
tk.Label(
keyboard_frame,
text="TASTATUR (KEYBOARD)",
font=('Courier', 8),
fg='#8B7355',
bg=COLOR_PANEL,
).pack(pady=(5, 0))
canvas_width = 10 * KEY_SPACING + 40
canvas_height = 3 * KEY_SPACING + 30
self._key_canvas = tk.Canvas(
keyboard_frame,
width=canvas_width,
height=canvas_height,
bg=COLOR_PANEL,
highlightthickness=0,
)
self._key_canvas.pack(pady=5)
for row_idx, row in enumerate(KEYBOARD_ROWS):
row_width = len(row) * KEY_SPACING
x_start = (canvas_width - row_width) // 2 + KEY_SPACING // 2
y = 30 + row_idx * KEY_SPACING
for col_idx, letter in enumerate(row):
x = x_start + col_idx * KEY_SPACING
half_w = KEY_WIDTH // 2
half_h = KEY_HEIGHT // 2
key_bg = create_rounded_rectangle(
self._key_canvas,
x - half_w, y - half_h,
x + half_w, y + half_h,
radius=KEY_RADIUS,
fill=COLOR_KEY_IDLE,
outline='#8B7355',
width=1,
)
key_text = self._key_canvas.create_text(
x, y,
text=letter,
font=('Courier', 13, 'bold'),
fill=COLOR_KEY_TEXT,
)
self._key_items[letter] = (key_bg, key_text)
# Bind mouse click to both the background polygon and the text.
# The lambda uses a default argument (l=letter) to capture the
# current value of 'letter' rather than the loop variable.
for item_id in (key_bg, key_text):
self._key_canvas.tag_bind(
item_id,
'<ButtonPress-1>',
lambda event, l=letter: self._on_key_press(l),
)
def _build_plugboard_display(self, parent):
"""
Create the plugboard visualization panel.
The plugboard display shows the 26 letter sockets arranged in two
rows of 13, with colored curved lines connecting plugged pairs.
Each cable connection is drawn in a distinct color so that operators
can easily see which letters are connected. A Configure button opens
a dialog for adding and removing cable connections.
"""
plug_frame = tk.Frame(
parent,
bg=COLOR_PLUGBOARD_BODY,
relief=tk.SUNKEN,
borderwidth=2,
)
plug_frame.pack(padx=20, pady=5, fill=tk.X)
header_frame = tk.Frame(plug_frame, bg=COLOR_PLUGBOARD_BODY)
header_frame.pack(fill=tk.X, padx=10, pady=(5, 0))
tk.Label(
header_frame,
text="STECKERBRETT (PLUGBOARD)",
font=('Courier', 8),
fg='#8B7355',
bg=COLOR_PLUGBOARD_BODY,
).pack(side=tk.LEFT)
tk.Button(
header_frame,
text="Configure",
font=('Courier', 8),
fg='#C8A96E',
bg=COLOR_PANEL,
relief=tk.FLAT,
command=self._open_plugboard_dialog,
).pack(side=tk.RIGHT)
canvas_width = 10 * KEY_SPACING + 40
canvas_height = 120
self._plug_canvas = tk.Canvas(
plug_frame,
width=canvas_width,
height=canvas_height,
bg=COLOR_PLUGBOARD_BODY,
highlightthickness=0,
)
self._plug_canvas.pack(pady=5)
# Draw the 26 sockets in two rows of 13.
self._socket_positions = {}
socket_letters = list(ALPHABET)
for row in range(2):
for col in range(13):
letter = socket_letters[row * 13 + col]
x = 30 + col * (canvas_width - 60) // 12
y = 30 + row * 55
self._socket_positions[letter] = (x, y)
self._plug_canvas.create_oval(
x - 10, y - 10, x + 10, y + 10,
fill='#2C1810',
outline='#8B7355',
width=2,
)
self._plug_canvas.create_text(
x, y,
text=letter,
font=('Courier', 8, 'bold'),
fill='#8B7355',
)
self._redraw_plugboard_cables()
def _build_message_panel(self, parent):
"""
Create the message log panel showing input and output text.
The panel displays two text areas side by side: the plaintext
input on the left and the ciphertext output on the right. This
allows the operator to see both the message being typed and its
encrypted form simultaneously. Text is formatted in groups of
five characters separated by spaces, following the historical
Enigma message format used by Wehrmacht operators.
"""
msg_frame = tk.Frame(parent, bg=COLOR_MACHINE_BODY)
msg_frame.pack(padx=20, pady=5, fill=tk.X)
input_frame = tk.Frame(msg_frame, bg=COLOR_PANEL)
input_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(0, 5))
tk.Label(
input_frame,
text="INPUT (PLAINTEXT)",
font=('Courier', 8),
fg='#8B7355',
bg=COLOR_PANEL,
).pack(anchor=tk.W, padx=5, pady=(3, 0))
self._input_display = tk.Text(
input_frame,
height=4,
font=('Courier', 11),
bg=COLOR_PANEL,
fg='#C8A96E',
insertbackground='#C8A96E',
state=tk.DISABLED,
wrap=tk.WORD,
relief=tk.FLAT,
)
self._input_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
output_frame = tk.Frame(msg_frame, bg=COLOR_PANEL)
output_frame.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(5, 0))
tk.Label(
output_frame,
text="OUTPUT (CIPHERTEXT)",
font=('Courier', 8),
fg='#8B7355',
bg=COLOR_PANEL,
).pack(anchor=tk.W, padx=5, pady=(3, 0))
self._output_display = tk.Text(
output_frame,
height=4,
font=('Courier', 11),
bg=COLOR_PANEL,
fg='#FFD700',
state=tk.DISABLED,
wrap=tk.WORD,
relief=tk.FLAT,
)
self._output_display.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
def _build_control_panel(self, parent):
"""
Create the control panel with buttons for machine operations.
The control panel provides buttons for opening the machine settings
dialog, resetting the rotor positions, clearing the message log,
opening the network connection dialog, and displaying application
information. A network status indicator on the right shows whether
a peer connection is currently active.
"""
ctrl_frame = tk.Frame(parent, bg=COLOR_MACHINE_BODY)
ctrl_frame.pack(padx=20, pady=(5, 15), fill=tk.X)
button_configs = [
("Settings", self._open_settings_dialog, '#C8A96E', '#1A0F0A'),
("Reset", self._reset_machine, COLOR_PANEL, '#C8A96E'),
("Clear Log", self._clear_message_log, COLOR_PANEL, '#C8A96E'),
("Network", self._open_network_dialog, '#4A7C59', '#F5F0E8'),
("About", self._show_about, COLOR_PANEL, '#8B7355'),
]
for text, command, bg, fg in button_configs:
tk.Button(
ctrl_frame,
text=text,
font=('Courier', 9),
fg=fg,
bg=bg,
relief=tk.FLAT,
padx=10,
pady=5,
command=command,
).pack(side=tk.LEFT, padx=5)
self._network_status_label = tk.Label(
ctrl_frame,
text="OFFLINE",
font=('Courier', 8, 'bold'),
fg='#FF4444',
bg=COLOR_MACHINE_BODY,
)
self._network_status_label.pack(side=tk.RIGHT, padx=10)
# -----------------------------------------------------------------------
# Animation methods
# -----------------------------------------------------------------------
def _animate_key_press(self, letter):
"""
Animate a key being pressed down and released.
Changes the key's background color to the pressed color and
schedules a callback to restore it after ANIM_KEY_PRESS_DURATION
milliseconds. If the key is already in a pressed animation, the
pending release is cancelled and restarted.
Args:
letter: The letter whose key should be animated.
"""
if letter not in self._key_items:
return
key_bg, key_text = self._key_items[letter]
# Cancel any pending release animation for this key.
if letter in self._pressed_keys:
self.root.after_cancel(self._pressed_keys[letter])
self._key_canvas.itemconfig(key_bg, fill=COLOR_KEY_PRESSED)
self._key_canvas.itemconfig(key_text, fill='#5A3D2B')
after_id = self.root.after(
ANIM_KEY_PRESS_DURATION,
lambda: self._animate_key_release(letter),
)
self._pressed_keys[letter] = after_id
def _animate_key_release(self, letter):
"""
Animate a key springing back to its resting position.
Called automatically after ANIM_KEY_PRESS_DURATION milliseconds
by the scheduled callback created in _animate_key_press.
Args:
letter: The letter whose key should be released.
"""
if letter not in self._key_items:
return
key_bg, key_text = self._key_items[letter]
self._key_canvas.itemconfig(key_bg, fill=COLOR_KEY_IDLE)
self._key_canvas.itemconfig(key_text, fill=COLOR_KEY_TEXT)
self._pressed_keys.pop(letter, None)
def _animate_lamp_on(self, letter):
"""
Illuminate a lamp on the lampboard with a glow effect.
The lamp immediately changes to bright gold and the glow circle
becomes visible. After ANIM_LAMP_ON_DURATION milliseconds, the
lamp begins a gradual fade back to its dark resting color through
a series of color interpolation steps.
Args:
letter: The letter whose lamp should illuminate.
"""
if letter not in self._lamp_items:
return
lamp_id, text_id, glow_id = self._lamp_items[letter]
# Cancel any pending fade animation for this lamp.
for after_id in self._lamp_fade_ids.pop(letter, []):
self.root.after_cancel(after_id)
if letter in self._lit_lamps:
self.root.after_cancel(self._lit_lamps.pop(letter))
# Turn the lamp on immediately.
self._lamp_canvas.itemconfig(lamp_id, fill=COLOR_LAMP_ON, outline='#FFD700')
self._lamp_canvas.itemconfig(text_id, fill='#1A0F0A')
self._lamp_canvas.itemconfig(glow_id, fill=COLOR_LAMP_GLOW)
# Schedule the fade-out sequence to begin after the lamp-on duration.
after_id = self.root.after(
ANIM_LAMP_ON_DURATION,
lambda: self._animate_lamp_fade(letter),
)
self._lit_lamps[letter] = after_id
def _animate_lamp_fade(self, letter):
"""
Gradually fade a lamp from bright gold back to dark amber.
Creates a more realistic lamp effect than an instant switch-off.
The fade is implemented as ANIM_LAMP_FADE_STEPS color interpolation
steps scheduled with short delays between them. Each step linearly
interpolates between the lit color (COLOR_LAMP_ON) and the unlit
color (COLOR_LAMP_OFF). The glow circle fades 50 percent faster
than the lamp itself.
Args:
letter: The letter whose lamp should fade out.
"""
if letter not in self._lamp_items:
return
lamp_id, text_id, glow_id = self._lamp_items[letter]
# Start and end colors as (r, g, b) integer tuples.
start_r, start_g, start_b = 0xFF, 0xD7, 0x00 # COLOR_LAMP_ON
end_r, end_g, end_b = 0x3D, 0x2B, 0x1F # COLOR_LAMP_OFF
# Glow start and end colors.
glow_sr, glow_sg, glow_sb = 0xFF, 0xA5, 0x00 # COLOR_LAMP_GLOW
glow_er, glow_eg, glow_eb = 0x1A, 0x0F, 0x0A # COLOR_PANEL
step_delay_ms = max(1, ANIM_LAMP_ON_DURATION // ANIM_LAMP_FADE_STEPS // 3)
fade_ids = []
for step in range(ANIM_LAMP_FADE_STEPS + 1):
t = step / ANIM_LAMP_FADE_STEPS # 0.0 -> 1.0
glow_t = min(1.0, t * 1.5) # glow fades faster
r = int(start_r + (end_r - start_r) * t)
g = int(start_g + (end_g - start_g) * t)
b = int(start_b + (end_b - start_b) * t)
color = f'#{r:02X}{g:02X}{b:02X}'
gr = int(glow_sr + (glow_er - glow_sr) * glow_t)
gg = int(glow_sg + (glow_eg - glow_sg) * glow_t)
gb = int(glow_sb + (glow_eb - glow_sb) * glow_t)
glow_color = f'#{gr:02X}{gg:02X}{gb:02X}'
delay = step * step_delay_ms
aid = self.root.after(
delay,
lambda c=color, gc=glow_color, li=lamp_id, gi=glow_id, ti=text_id:
self._apply_lamp_fade_step(li, gi, ti, c, gc),
)
fade_ids.append(aid)
self._lamp_fade_ids[letter] = fade_ids
def _apply_lamp_fade_step(self, lamp_id, glow_id, text_id, color, glow_color):
"""
Apply a single step of the lamp fade animation.
Called by the scheduled callbacks created in _animate_lamp_fade.
Updates the canvas item colors for the lamp circle and glow circle,
and restores the text color to its dim resting state.
Args:
lamp_id: Canvas item ID of the lamp circle.
glow_id: Canvas item ID of the glow circle.
text_id: Canvas item ID of the lamp letter text.
color: The interpolated lamp color for this step.
glow_color: The interpolated glow color for this step.
"""
try:
self._lamp_canvas.itemconfig(lamp_id, fill=color)
self._lamp_canvas.itemconfig(glow_id, fill=glow_color)
self._lamp_canvas.itemconfig(text_id, fill='#8B7355')
except tk.TclError:
# The canvas may have been destroyed during application shutdown.
pass
def _animate_rotor_step(self, rotor_index, new_letter):
"""
Animate the rotor window display changing to a new letter.
Briefly flashes the rotor window background to indicate that the
rotor has stepped, then updates the displayed letter. This gives
a clear visual indication of which rotors are stepping with each
keypress.
Args:
rotor_index: 0 for left, 1 for middle, 2 for right rotor.
new_letter: The new letter to display after stepping.
"""
canvas = self._rotor_canvases[rotor_index]
text_id = self._rotor_text_ids[rotor_index]
canvas.configure(bg='#4A3728')
self.root.after(
ANIM_ROTOR_FLASH_HALF,
lambda: self._finish_rotor_step(canvas, text_id, new_letter),
)
def _finish_rotor_step(self, canvas, text_id, new_letter):
"""
Complete the rotor step animation by restoring the background
and updating the displayed letter.
Args:
canvas: The rotor window Canvas widget.
text_id: The canvas item ID of the letter text.
new_letter: The new letter to display.
"""
try:
canvas.configure(bg=COLOR_ROTOR_WINDOW)
canvas.itemconfig(text_id, text=new_letter)
except tk.TclError:
pass
def _update_rotor_display(self, previous_position):
"""
Update the rotor display windows after a keypress.
Compares the current rotor positions with the positions before the
keypress and animates any rotors that have stepped. Rotors that did
not step have their display updated without animation.
Args:
previous_position: The three-letter rotor position string
captured before the keypress.
"""
current_position = self.machine.rotor_display
for i in range(3):
if current_position[i] != previous_position[i]:
self._animate_rotor_step(i, current_position[i])
else:
self._rotor_canvases[i].itemconfig(
self._rotor_text_ids[i],
text=current_position[i],
)
# -----------------------------------------------------------------------
# Core interaction
# -----------------------------------------------------------------------
def _on_key_press(self, letter):
"""
Handle a key press event — the central interaction method.
Orchestrates the complete response to a keypress: records the rotor
positions before encryption, encrypts the letter through the engine,
animates the pressed key, updates the rotor display, illuminates the
output lamp, updates the message log, and sends the encrypted
character to the connected peer if a network session is active.
In client mode (self.network_client is set), the encrypted character
is sent to the server via the REST API. In server mode
(self._enigma_server is set and a peer is connected), the encrypted
character is placed in the server's outgoing queue for the client
to retrieve via polling.
Args:
letter: The uppercase letter that was pressed.
"""
if letter not in ALPHABET:
return
previous_rotor_position = self.machine.rotor_display
# Encrypt through the cryptographic engine.
encrypted_letter = self.machine.encrypt_letter(letter)
# Animate the physical key press.
self._animate_key_press(letter)
# Update the rotor display windows.
self._update_rotor_display(previous_rotor_position)
# Illuminate the output lamp.
self._animate_lamp_on(encrypted_letter)
# Update the message log.
self._input_text.append(letter)
self._output_text.append(encrypted_letter)
self._update_message_log()
# Send to connected peer if a network session is active.
if self._peer_connected:
if self.network_client:
# Client mode: send via REST to the server.
threading.Thread(
target=self._send_to_peer_client,
args=(letter, encrypted_letter),
daemon=True,
).start()
elif self._enigma_server and self._enigma_server.is_connected:
# Server mode: place in outgoing queue for client to poll.
threading.Thread(
target=self._send_to_peer_server,
args=(encrypted_letter,),
daemon=True,
).start()
def _on_keyboard_input(self, event):
"""
Handle keyboard input from the physical keyboard.
Bound to the root window's <Key> event. Translates keyboard events
into key press actions, filtering out non-letter keys and special
keys such as function keys, modifier keys, and control sequences.
Args:
event: The Tkinter keyboard event object.
"""
letter = event.char.upper()
if letter in ALPHABET:
self._on_key_press(letter)
def _update_message_log(self):
"""
Update the input and output text displays with current text.
Called after every keypress to keep the message log synchronized
with the current encryption state. Text is formatted in groups of
five characters separated by spaces, following the historical
Enigma message format.
"""
def format_text(chars):
text = ''.join(chars)
groups = [text[i:i + 5] for i in range(0, len(text), 5)]
return ' '.join(groups)
input_text = format_text(self._input_text)
output_text = format_text(self._output_text)
for display, text in [
(self._input_display, input_text),
(self._output_display, output_text),
]:
display.config(state=tk.NORMAL)
display.delete('1.0', tk.END)
display.insert('1.0', text)
display.see(tk.END)
display.config(state=tk.DISABLED)
# -----------------------------------------------------------------------
# Manual rotor controls
# -----------------------------------------------------------------------
def _manual_rotor_advance(self, rotor_index):
"""
Manually advance a rotor by one position.
Simulates the operator manually turning the rotor before starting
to type a message. The rotor display is updated immediately without
the key press animation, since no encryption is taking place.
Args:
rotor_index: 0 for left, 1 for middle, 2 for right rotor.
"""
self.machine._rotors[rotor_index].step()
new_letter = self.machine._rotors[rotor_index].display_letter
self._rotor_canvases[rotor_index].itemconfig(
self._rotor_text_ids[rotor_index],
text=new_letter,
)
def _manual_rotor_retract(self, rotor_index):
"""
Manually retract a rotor by one position (step backward).
The reverse of advancing a rotor. The offset is decremented by one,
wrapping around from A (0) to Z (25) using Python's modulo behavior
with negative numbers: (-1) % 26 == 25.
Args:
rotor_index: 0 for left, 1 for middle, 2 for right rotor.
"""
rotor = self.machine._rotors[rotor_index]
rotor.offset = (rotor.offset - 1) % 26
new_letter = rotor.display_letter
self._rotor_canvases[rotor_index].itemconfig(
self._rotor_text_ids[rotor_index],
text=new_letter,
)
# -----------------------------------------------------------------------
# Plugboard
# -----------------------------------------------------------------------
def _redraw_plugboard_cables(self):
"""
Redraw all plugboard cable connections on the canvas.
Clears all existing cable drawings and redraws them based on the
current plugboard configuration. Called whenever the plugboard
configuration changes.
"""
for item_ids in self._plug_wire_items.values():
for item_id in item_ids:
self._plug_canvas.delete(item_id)
self._plug_wire_items.clear()
pairs = self.machine._plugboard.get_pairs()
for pair_idx, pair in enumerate(pairs):
color = COLOR_WIRE_COLORS[pair_idx % len(COLOR_WIRE_COLORS)]
a, b = pair[0], pair[1]
if a not in self._socket_positions or b not in self._socket_positions:
continue
x1, y1 = self._socket_positions[a]
x2, y2 = self._socket_positions[b]
# The cable droops below the sockets for a realistic appearance.
mid_x = (x1 + x2) / 2
mid_y = max(y1, y2) + 20
item_ids = []
wire_id = self._plug_canvas.create_line(
x1, y1, mid_x, mid_y, x2, y2,
fill=color,
width=3,
smooth=True,
)
item_ids.append(wire_id)
# Colored dots at each socket end to show the connection clearly.
for x, y in [(x1, y1), (x2, y2)]:
dot_id = self._plug_canvas.create_oval(
x - 6, y - 6, x + 6, y + 6,
fill=color,
outline='',
)
item_ids.append(dot_id)
self._plug_wire_items[pair] = item_ids
def _open_plugboard_dialog(self):
"""
Open a dialog window for configuring the plugboard cables.
The dialog allows the user to enter plugboard pairs as a
space-separated string of two-letter pairs (e.g., "AB CD EF").
It validates the input and updates the plugboard configuration.
The machine's plugboard is replaced atomically on success.
"""
current_pairs = ' '.join(self.machine._plugboard.get_pairs())
dialog = tk.Toplevel(self.root)
dialog.title("Plugboard Configuration")
dialog.configure(bg=COLOR_MACHINE_BODY)
dialog.resizable(False, False)
dialog.transient(self.root)
dialog.grab_set()
tk.Label(
dialog,
text="Enter plugboard pairs (e.g.: AB CD EF GH IJ)",
font=('Courier', 10),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
).pack(padx=20, pady=(15, 5))
tk.Label(
dialog,
text="Maximum 13 pairs. Separate pairs with spaces.",
font=('Courier', 8),
fg='#8B7355',
bg=COLOR_MACHINE_BODY,
).pack(padx=20)
entry_var = tk.StringVar(value=current_pairs)
entry = tk.Entry(
dialog,
textvariable=entry_var,
font=('Courier', 12),
bg=COLOR_PANEL,
fg='#C8A96E',
insertbackground='#C8A96E',
width=40,
)
entry.pack(padx=20, pady=10)
entry.select_range(0, tk.END)
error_label = tk.Label(
dialog,
text='',
font=('Courier', 9),
fg='#FF4444',
bg=COLOR_MACHINE_BODY,
)
error_label.pack()
def apply_configuration():
pairs_text = entry_var.get().strip().upper()
pairs = [p.strip() for p in pairs_text.split() if p.strip()]
try:
new_plugboard = Plugboard(pairs if pairs else None)
self.machine._plugboard = new_plugboard
self._redraw_plugboard_cables()
dialog.destroy()
except ValueError as e:
error_label.config(text=str(e))
button_frame = tk.Frame(dialog, bg=COLOR_MACHINE_BODY)
button_frame.pack(pady=15)
tk.Button(
button_frame,
text="Apply",
font=('Courier', 10),
fg='#1A0F0A',
bg='#C8A96E',
command=apply_configuration,
).pack(side=tk.LEFT, padx=10)
tk.Button(
button_frame,
text="Cancel",
font=('Courier', 10),
fg='#C8A96E',
bg=COLOR_PANEL,
command=dialog.destroy,
).pack(side=tk.LEFT, padx=10)
# -----------------------------------------------------------------------
# Settings dialog
# -----------------------------------------------------------------------
def _open_settings_dialog(self):
"""
Open the machine settings dialog for configuring rotors and ring settings.
Allows the operator to select which rotors to use, in which order,
what reflector to use, what ring settings to apply, and what initial
rotor positions to start from. These settings correspond exactly to
the daily key settings that Enigma operators received in their key
books (Schluesselbuecher). Both operators in a networked session must
configure their machines with identical settings.
"""
dialog = tk.Toplevel(self.root)
dialog.title("Machine Settings (Maschinenschluessel)")
dialog.configure(bg=COLOR_MACHINE_BODY)
dialog.resizable(False, False)
dialog.transient(self.root)
dialog.grab_set()
current_state = self.machine.get_state()
tk.Label(
dialog,
text="ENIGMA MACHINE SETTINGS",
font=('Courier', 12, 'bold'),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
).pack(pady=(15, 5))
tk.Label(
dialog,
text="Both operators must use identical settings.",
font=('Courier', 9),
fg='#8B7355',
bg=COLOR_MACHINE_BODY,
).pack(pady=(0, 15))
settings_frame = tk.Frame(dialog, bg=COLOR_MACHINE_BODY)
settings_frame.pack(padx=20, pady=5)
rotor_vars = []
rotor_names = list(ROTOR_WIRINGS.keys())
for i, position in enumerate(['Left Rotor', 'Middle Rotor', 'Right Rotor']):
row_frame = tk.Frame(settings_frame, bg=COLOR_MACHINE_BODY)
row_frame.pack(fill=tk.X, pady=3)
tk.Label(
row_frame,
text=f"{position}:",
font=('Courier', 10),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
width=15,
anchor=tk.W,
).pack(side=tk.LEFT)
var = tk.StringVar(value=current_state['rotors'][i])
rotor_vars.append(var)
ttk.Combobox(
row_frame,
textvariable=var,
values=rotor_names,
width=8,
state='readonly',
).pack(side=tk.LEFT, padx=5)
# Reflector selection.
ref_frame = tk.Frame(settings_frame, bg=COLOR_MACHINE_BODY)
ref_frame.pack(fill=tk.X, pady=3)
tk.Label(
ref_frame,
text="Reflector:",
font=('Courier', 10),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
width=15,
anchor=tk.W,
).pack(side=tk.LEFT)
reflector_var = tk.StringVar(value=current_state['reflector'])
ttk.Combobox(
ref_frame,
textvariable=reflector_var,
values=list(REFLECTOR_WIRINGS.keys()),
width=8,
state='readonly',
).pack(side=tk.LEFT, padx=5)
# Ring settings entry.
ring_frame = tk.Frame(settings_frame, bg=COLOR_MACHINE_BODY)
ring_frame.pack(fill=tk.X, pady=3)
tk.Label(
ring_frame,
text="Ring Settings:",
font=('Courier', 10),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
width=15,
anchor=tk.W,
).pack(side=tk.LEFT)
ring_var = tk.StringVar(value=current_state['ring_settings'])
tk.Entry(
ring_frame,
textvariable=ring_var,
font=('Courier', 12),
bg=COLOR_PANEL,
fg='#C8A96E',
insertbackground='#C8A96E',
width=6,
).pack(side=tk.LEFT, padx=5)
tk.Label(
ring_frame,
text="(3 letters, e.g. AAA)",
font=('Courier', 8),
fg='#8B7355',
bg=COLOR_MACHINE_BODY,
).pack(side=tk.LEFT)
# Initial positions entry.
pos_frame = tk.Frame(settings_frame, bg=COLOR_MACHINE_BODY)
pos_frame.pack(fill=tk.X, pady=3)
tk.Label(
pos_frame,
text="Start Position:",
font=('Courier', 10),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
width=15,
anchor=tk.W,
).pack(side=tk.LEFT)
pos_var = tk.StringVar(value=current_state['current_positions'])
tk.Entry(
pos_frame,
textvariable=pos_var,
font=('Courier', 12),
bg=COLOR_PANEL,
fg='#C8A96E',
insertbackground='#C8A96E',
width=6,
).pack(side=tk.LEFT, padx=5)
tk.Label(
pos_frame,
text="(3 letters, e.g. AAA)",
font=('Courier', 8),
fg='#8B7355',
bg=COLOR_MACHINE_BODY,
).pack(side=tk.LEFT)
error_label = tk.Label(
dialog,
text='',
font=('Courier', 9),
fg='#FF4444',
bg=COLOR_MACHINE_BODY,
)
error_label.pack()
def apply_settings():
try:
rotors = [v.get() for v in rotor_vars]
if len(set(rotors)) != 3:
error_label.config(
text="Error: Each rotor must be different."
)
return
ring_settings = ring_var.get().upper().strip()
if (len(ring_settings) != 3
or not all(c in ALPHABET for c in ring_settings)):
error_label.config(
text="Error: Ring settings must be 3 letters (e.g. AAA)."
)
return
initial_positions = pos_var.get().upper().strip()
if (len(initial_positions) != 3
or not all(c in ALPHABET for c in initial_positions)):
error_label.config(
text="Error: Start position must be 3 letters (e.g. AAA)."
)
return
current_pairs = self.machine._plugboard.get_pairs()
self.machine = EnigmaMachine.from_config({
'rotors': rotors,
'reflector': reflector_var.get(),
'ring_settings': ring_settings,
'initial_positions': initial_positions,
'plugboard_pairs': current_pairs,
})
for i in range(3):
self._rotor_canvases[i].itemconfig(
self._rotor_text_ids[i],
text=self.machine.rotor_display[i],
)
self._clear_message_log()
dialog.destroy()
except Exception as e:
error_label.config(text=f"Error: {str(e)}")
button_frame = tk.Frame(dialog, bg=COLOR_MACHINE_BODY)
button_frame.pack(pady=15)
tk.Button(
button_frame,
text="Apply Settings",
font=('Courier', 10),
fg='#1A0F0A',
bg='#C8A96E',
command=apply_settings,
).pack(side=tk.LEFT, padx=10)
tk.Button(
button_frame,
text="Cancel",
font=('Courier', 10),
fg='#C8A96E',
bg=COLOR_PANEL,
command=dialog.destroy,
).pack(side=tk.LEFT, padx=10)
# -----------------------------------------------------------------------
# Machine control
# -----------------------------------------------------------------------
def _reset_machine(self):
"""
Reset the rotor positions to their configured starting positions.
Does not change the ring settings, rotor selection, or plugboard
configuration; only the rotor offsets are restored. Equivalent to
the operator resetting the rotors to the message key before starting
a new message.
"""
self.machine.reset()
for i in range(3):
self._rotor_canvases[i].itemconfig(
self._rotor_text_ids[i],
text=self.machine.rotor_display[i],
)
def _clear_message_log(self):
"""
Clear the input and output message displays.
Clears the visual log and the internal character lists but does not
reset the machine state. Operators would clear the log between
messages while keeping the machine configured for the current session.
"""
self._input_text.clear()
self._output_text.clear()
for display in (self._input_display, self._output_display):
display.config(state=tk.NORMAL)
display.delete('1.0', tk.END)
display.config(state=tk.DISABLED)
def _show_about(self):
"""Display information about the Enigma machine simulator."""
messagebox.showinfo(
"About Enigma Machine Simulator",
"Enigma Machine Simulator\n"
"A faithful software implementation of the Wehrmacht Enigma\n\n"
"Rotor specifications sourced from:\n"
" The Crypto Museum (cryptomuseum.com)\n"
" 'Seizing the Enigma' by David Kahn\n\n"
"Implements all five Wehrmacht rotors (I-V),\n"
"Reflectors B and C, ring settings, and plugboard.\n\n"
"The double-stepping anomaly is correctly simulated.\n\n"
"Note: Enigma is not secure by modern standards.\n"
"This application is for educational purposes only.",
)
# -----------------------------------------------------------------------
# Network integration
# -----------------------------------------------------------------------
def _send_to_peer_client(self, plaintext_letter, ciphertext_letter):
"""
Send an encrypted character to the server (client mode).
Runs in a background thread to avoid blocking the GUI event loop.
Calls the network client to transmit the character and handles any
connection errors by updating the network status on the main thread.
Args:
plaintext_letter: The original letter that was pressed.
ciphertext_letter: The encrypted output letter to send.
"""
if not self.network_client:
return
try:
self.network_client.send_character(
plaintext_letter,
ciphertext_letter,
)
except Exception as e:
self.root.after(
0,
lambda err=str(e): self._update_network_status('offline', err),
)
def _send_to_peer_server(self, ciphertext_letter):
"""
Place an encrypted character in the server's outgoing queue (server mode).
Runs in a background thread. The character is placed in the server's
outgoing queue, from which the connected client retrieves it via the
GET /api/message/receive polling endpoint.
Args:
ciphertext_letter: The encrypted output letter to send to the client.
"""
if not self._enigma_server:
return
try:
self._enigma_server.send_character(ciphertext_letter)
except Exception as e:
self.root.after(
0,
lambda err=str(e): self._update_network_status('offline', err),
)
def _receive_character(self, ciphertext_letter):
"""
Handle a character received from the connected peer.
Called on the main GUI thread via root.after() from either the
server's character callback (server mode) or the client's polling
loop callback (client mode). Illuminates the corresponding lamp
to show the received character and appends it to the output log.
The received character is the ciphertext from the peer's perspective.
If both machines are configured identically and the peer pressed a
plaintext key that encrypted to this character, then pressing this
character on the local machine will decrypt it to the peer's original
plaintext letter.
Args:
ciphertext_letter: The encrypted character received from the peer.
"""
ciphertext_letter = ciphertext_letter.upper()
if ciphertext_letter not in ALPHABET:
return
# Illuminate the lamp for the received character.
self._animate_lamp_on(ciphertext_letter)
# Append to the output display as a received character.
self._output_text.append(ciphertext_letter)
self._update_message_log()
def _update_network_status(self, state, message=''):
"""
Update the network status indicator in the control panel.
Args:
state: One of 'offline', 'listening', or 'connected'.
message: Optional detail string (for future tooltip integration).
"""
if state == 'connected':
self._peer_connected = True
self._network_status_label.config(
text="CONNECTED",
fg='#44FF44',
)
elif state == 'listening':
self._peer_connected = False
self._network_status_label.config(
text="LISTENING",
fg='#FFD700',
)
else:
self._peer_connected = False
self._network_status_label.config(
text="OFFLINE",
fg='#FF4444',
)
def _open_network_dialog(self):
"""
Open the network configuration dialog.
Provides two modes of operation presented as tabs in a Notebook widget.
In Server mode, the user starts a local Flask REST server and waits
for a peer to connect. The server's IP address and port are displayed
so that the peer knows where to connect. When the peer connects, the
status changes from LISTENING to CONNECTED and both operators can
exchange messages bidirectionally.
In Client mode, the user enters the server's IP address and port and
initiates a connection. After connecting, the configurations are
compared and any mismatches are reported so the operator can correct
them before exchanging messages.
"""
import socket
from enigma_server import EnigmaServer
from enigma_client import EnigmaClient
dialog = tk.Toplevel(self.root)
dialog.title("Network Configuration")
dialog.configure(bg=COLOR_MACHINE_BODY)
dialog.resizable(False, False)
dialog.transient(self.root)
dialog.grab_set()
tk.Label(
dialog,
text="NETWORK CONNECTION",
font=('Courier', 12, 'bold'),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
).pack(pady=(15, 5))
# Determine the local IP address for display in server mode.
try:
# Connect to a public address (no data is sent) to discover
# the local IP that would be used for outgoing connections.
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
local_ip = s.getsockname()[0]
s.close()
except Exception:
local_ip = '127.0.0.1'
notebook = ttk.Notebook(dialog)
notebook.pack(padx=20, pady=10, fill=tk.BOTH)
# ---- Server Tab ----
server_tab = tk.Frame(notebook, bg=COLOR_MACHINE_BODY)
notebook.add(server_tab, text=' Start Server ')
tk.Label(
server_tab,
text=f"Your IP Address: {local_ip}",
font=('Courier', 10),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
).pack(pady=(15, 5))
port_frame = tk.Frame(server_tab, bg=COLOR_MACHINE_BODY)
port_frame.pack(pady=5)
tk.Label(
port_frame,
text="Port:",
font=('Courier', 10),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
).pack(side=tk.LEFT)
server_port_var = tk.StringVar(value=str(self._default_server_port))
tk.Entry(
port_frame,
textvariable=server_port_var,
font=('Courier', 12),
bg=COLOR_PANEL,
fg='#C8A96E',
insertbackground='#C8A96E',
width=8,
).pack(side=tk.LEFT, padx=5)
server_status_label = tk.Label(
server_tab,
text='Server not running',
font=('Courier', 9),
fg='#8B7355',
bg=COLOR_MACHINE_BODY,
)
server_status_label.pack(pady=5)
def on_peer_connected():
"""Called by the server when a peer successfully connects."""
self.root.after(0, lambda: (
server_status_label.config(
text='Peer connected — session active',
fg='#44FF44',
),
self._update_network_status('connected'),
))
def start_server():
try:
port = int(server_port_var.get())
if not (1024 <= port <= 65535):
server_status_label.config(
text="Port must be between 1024 and 65535",
fg='#FF4444',
)
return
# Stop any previously running server.
if self._enigma_server is not None:
self._enigma_server.stop()
self._enigma_server = EnigmaServer(host='0.0.0.0', port=port)
self._enigma_server.set_machine_config(self.machine.get_state())
# Character callback: called by the server when the client
# sends a character. Schedules _receive_character on the
# main GUI thread.
self._enigma_server.set_character_callback(
lambda c: self.root.after(
0, lambda ch=c: self._receive_character(ch)
)
)
# Peer-connected callback: called by the server when a peer
# successfully completes the /api/connect handshake.
self._enigma_server.set_peer_connected_callback(on_peer_connected)
self._enigma_server.start()
server_status_label.config(
text=f"Listening on {local_ip}:{port} — waiting for peer",
fg='#FFD700',
)
self._update_network_status('listening')
except ValueError:
server_status_label.config(
text="Invalid port number",
fg='#FF4444',
)
except Exception as e:
server_status_label.config(
text=f"Error: {str(e)}",
fg='#FF4444',
)
tk.Button(
server_tab,
text="Start Server",
font=('Courier', 10),
fg='#1A0F0A',
bg='#C8A96E',
command=start_server,
).pack(pady=10)
# ---- Client Tab ----
client_tab = tk.Frame(notebook, bg=COLOR_MACHINE_BODY)
notebook.add(client_tab, text=' Connect to Server ')
tk.Label(
client_tab,
text="Server Address (e.g. http://192.168.1.100:5000):",
font=('Courier', 9),
fg='#C8A96E',
bg=COLOR_MACHINE_BODY,
).pack(pady=(15, 5))
server_url_var = tk.StringVar(
value=f'http://192.168.1.100:{self._default_server_port}'
)
tk.Entry(
client_tab,
textvariable=server_url_var,
font=('Courier', 11),
bg=COLOR_PANEL,
fg='#C8A96E',
insertbackground='#C8A96E',
width=38,
).pack(pady=5)
client_status_label = tk.Label(
client_tab,
text='Not connected',
font=('Courier', 9),
fg='#8B7355',
bg=COLOR_MACHINE_BODY,
wraplength=380,
)
client_status_label.pack(pady=5)
def connect_to_server():
client_status_label.config(text='Connecting...', fg='#FFD700')
dialog.update()
def do_connect():
try:
client = EnigmaClient(
server_url=server_url_var.get(),
local_config=self.machine.get_state(),
)
# The callback delivers received characters to the GUI.
# It is invoked from the polling thread, so root.after()
# is used to marshal the call to the main GUI thread.
client.set_character_callback(
lambda c: self.root.after(
0, lambda ch=c: self._receive_character(ch)
)
)
success, message = client.connect()
if success:
self.network_client = client
match, differences = client.verify_configuration_match()
if match:
self.root.after(0, lambda: (
client_status_label.config(
text='Connected! Configurations match.',
fg='#44FF44',
),
self._update_network_status('connected'),
))
else:
diff_text = ' | '.join(differences)
self.root.after(0, lambda dt=diff_text: (
client_status_label.config(
text=f'Connected but configs differ: {dt}',
fg='#FF8844',
),
self._update_network_status('connected'),
))
else:
self.root.after(0, lambda msg=message:
client_status_label.config(
text=f'Failed: {msg}',
fg='#FF4444',
)
)
except Exception as e:
self.root.after(0, lambda err=str(e):
client_status_label.config(
text=f'Error: {err}',
fg='#FF4444',
)
)
threading.Thread(target=do_connect, daemon=True).start()
tk.Button(
client_tab,
text="Connect",
font=('Courier', 10),
fg='#1A0F0A',
bg='#C8A96E',
command=connect_to_server,
).pack(pady=10)
def disconnect():
if self.network_client is not None:
if self.network_client.is_connected:
threading.Thread(
target=self.network_client.disconnect,
daemon=True,
).start()
self.network_client = None
if self._enigma_server is not None:
self._enigma_server.stop()
self._enigma_server = None
self._update_network_status('offline')
dialog.destroy()
tk.Button(
dialog,
text="Disconnect & Close",
font=('Courier', 10),
fg='#C8A96E',
bg=COLOR_PANEL,
command=disconnect,
).pack(pady=(5, 15))
The keyboard binding uses a well-known Python closure pattern: the lambda captures the letter variable using a default argument (l=letter). Without this default argument, all lambdas would capture the same loop variable letter, which would hold the value of the last letter processed by the time any lambda is invoked. The default argument forces immediate evaluation and binding of the current value.
The animation system is designed to be cancellation-safe. When _animate_lamp_on is called for a letter that already has a pending fade animation, all pending after() callbacks for that letter are cancelled before the new animation begins. This prevents visual artifacts that would occur if two overlapping fade sequences tried to update the same canvas items simultaneously.
CHAPTER SIX: THE NETWORKING LAYER — BIDIRECTIONAL REST DESIGN
6.1 Why REST for Enigma Communication
The choice of REST (Representational State Transfer) as the communication protocol for our networked Enigma is deliberate and instructive. REST is a stateless architectural style built on top of HTTP, which means it works across any network that supports HTTP: local area networks, the internet, and even through firewalls and NAT routers with appropriate port forwarding.
6.2 Solving Bidirectional Communication Over REST
REST is inherently request-response: the client initiates every exchange and the server responds. This creates an asymmetry problem for our Enigma application, because both operators need to send characters to each other. The solution is to make the server maintain two separate queues and to use the client's polling loop as the delivery mechanism for both directions.
When the client operator presses a key, the encrypted character is sent directly to the server via POST /api/message/character. The server receives it, places it in its display queue, and immediately invokes the character callback to show it on the server operator's lampboard. When the server operator presses a key, the encrypted character is placed in the server's outgoing queue via a call to server.send_character(). The client's polling loop calls GET /api/message/receive every 200 milliseconds; this endpoint drains the outgoing queue and returns its contents to the client, which then invokes the client's character callback to show the characters on the client's lampboard.
This design means that characters typed by the client operator appear on the server's lampboard almost instantly (within the HTTP round-trip time, typically 1-5ms on a LAN). Characters typed by the server operator appear on the client's lampboard within one polling interval (at most 200ms). Both directions work correctly and each character is delivered exactly once.
6.3 The REST API Endpoints
The server listens on a configurable port (default 5000) and accepts connections from any IP address. GET /api/status returns the server's current machine configuration and connection status. POST /api/connect is used by a client to establish a connection and exchange configurations; the body contains the client's peer ID and machine configuration, and the response contains the server's configuration. POST /api/message/character sends a single encrypted character from the client to the server; the character is stored in the display queue and the server's character callback is invoked immediately. GET /api/message/receive is the polling endpoint that returns all characters from the server's outgoing queue (characters the server operator typed) since the last poll. POST /api/disconnect cleanly terminates the connection and clears all session state.
6.4 Thread Safety
The server runs in a background daemon thread (Flask's Werkzeug development server). The character queues are queue.Queue instances, which are thread-safe by design. The connection state variables (_connected, _peer_id, _peer_config) are protected by a threading.Lock. The client uses two separate requests.Session objects — one for the polling loop thread and one for the send thread — to avoid concurrent access to a single session's internal state, which is not thread-safe.
The complete enigma_server.py file follows.
# enigma_server.py
#
# The REST API server for the networked Enigma machine.
# Implements a Flask-based HTTP server that allows two Enigma instances
# to connect and exchange encrypted messages bidirectionally.
#
# BIDIRECTIONAL DESIGN:
# _display_queue: Characters received FROM the client. The server
# calls _on_character_received immediately when a
# character arrives, so it appears on the server's
# lampboard without delay.
# _outgoing_queue: Characters typed BY the server operator. The client
# retrieves these via GET /api/message/receive polling.
#
# Dependencies:
# pip install flask flask-cors
from flask import Flask, request, jsonify
from flask_cors import CORS
import threading
import time
import uuid
import queue
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [ENIGMA-SERVER] %(levelname)s: %(message)s',
)
logger = logging.getLogger(__name__)
class EnigmaServer:
"""
The REST API server for the networked Enigma machine.
Wraps a Flask application and manages the server-side state for a
peer-to-peer Enigma connection. Only one peer connection is supported
at a time, reflecting the historical use case of point-to-point radio
communication.
The server runs in a background daemon thread so that it does not block
the Tkinter GUI event loop. Thread safety is ensured through the use of
threading.Lock for shared state and queue.Queue for the message buffers.
Bidirectional character delivery:
- Characters FROM client: stored in _display_queue; _on_character_received
callback is invoked immediately for server GUI display.
- Characters FROM server operator: stored in _outgoing_queue; retrieved
by the client via GET /api/message/receive polling.
"""
def __init__(self, host='0.0.0.0', port=5000):
"""
Initialize the Enigma server.
Args:
host: The IP address to bind to. '0.0.0.0' accepts connections
on all network interfaces, making the server reachable from
other machines on the network.
port: The TCP port to listen on. Default is 5000. Must be in the
range 1024-65535 to avoid requiring root privileges.
"""
self.host = host
self.port = port
# Shared state protected by _state_lock for thread safety.
self._state_lock = threading.Lock()
self._peer_id = None # ID of the connected peer
self._peer_config = None # Machine config received from peer
self._server_config = None # This server's machine config
self._connected = False # Whether a peer is connected
# Characters received FROM the client (displayed on server's GUI).
self._display_queue = queue.Queue()
# Characters typed BY the server operator (sent to client via polling).
self._outgoing_queue = queue.Queue()
# Callback invoked immediately when a character arrives from the client.
# Must be set before start(); called from the Flask request thread.
# The GUI registers this as: lambda c: root.after(0, lambda ch=c: ...)
self._on_character_received = None
# Callback invoked when a peer successfully completes the connect handshake.
# The GUI uses this to update the status indicator from LISTENING to CONNECTED.
self._on_peer_connected = None
self._app = Flask(__name__)
CORS(self._app)
self._register_routes()
self._server_thread = None
self._running = False
def set_machine_config(self, config):
"""
Set the machine configuration that will be shared with connecting peers.
Must be called before starting the server so that connecting peers
can retrieve the configuration for comparison.
Args:
config: A dictionary in the format returned by
EnigmaMachine.get_state().
"""
with self._state_lock:
self._server_config = config
logger.info(f"Machine configuration set: rotors={config.get('rotors')}")
def set_character_callback(self, callback):
"""
Register a callback invoked when a character arrives from the client.
The callback is called from the Flask request handler thread. The
GUI should register a callback that uses root.after() to safely
update Tkinter widgets from the main thread:
server.set_character_callback(
lambda c: root.after(0, lambda ch=c: gui._receive_character(ch))
)
Args:
callback: A callable that accepts a single uppercase letter string.
"""
self._on_character_received = callback
def set_peer_connected_callback(self, callback):
"""
Register a callback invoked when a peer successfully connects.
The callback is called from the Flask request handler thread with
no arguments. The GUI uses this to update the network status
indicator from LISTENING to CONNECTED.
Args:
callback: A callable that takes no arguments.
"""
self._on_peer_connected = callback
def send_character(self, ciphertext_letter):
"""
Place a character typed by the server operator into the outgoing queue.
Called by the GUI when the server operator presses a key during an
active session. The character is placed in _outgoing_queue, from
which the connected client retrieves it via GET /api/message/receive.
Args:
ciphertext_letter: The encrypted letter to send to the client.
"""
self._outgoing_queue.put({
'ciphertext': ciphertext_letter.upper(),
'timestamp': time.time(),
})
logger.debug(f"Queued outgoing character: '{ciphertext_letter}'")
def _register_routes(self):
"""
Register all Flask route handlers with the application.
Each route corresponds to one API endpoint. Flask's route decorator
associates a URL pattern and HTTP method with a handler function.
The handler functions are defined as closures so they have access
to the EnigmaServer instance through the enclosing scope.
"""
app = self._app
@app.route('/api/status', methods=['GET'])
def get_status():
"""
Return the server's current status and configuration.
Used by clients to check if the server is ready to accept
connections and to retrieve the server's machine configuration
for verification before starting a session.
"""
with self._state_lock:
return jsonify({
'status': 'ready',
'connected': self._connected,
'peer_id': self._peer_id,
'config': self._server_config,
'server_time': time.time(),
})
@app.route('/api/connect', methods=['POST'])
def connect_peer():
"""
Handle a connection request from a peer.
The peer sends its machine configuration, and the server responds
with its own configuration. Both sides can then verify that their
configurations match. The server only accepts one connection at a
time; if a peer is already connected, the request is rejected with
HTTP 409 Conflict.
"""
data = request.get_json(silent=True)
if not data:
return jsonify({
'accepted': False,
'error': 'Request body must be valid JSON',
}), 400
with self._state_lock:
if self._connected:
return jsonify({
'accepted': False,
'error': 'Server already has a connected peer',
}), 409
peer_id = data.get('peer_id', str(uuid.uuid4()))
peer_config = data.get('config', {})
self._peer_id = peer_id
self._peer_config = peer_config
self._connected = True
server_config = self._server_config
logger.info(f"Peer connected: {peer_id}")
# Notify the GUI that a peer has connected.
if self._on_peer_connected:
try:
self._on_peer_connected()
except Exception:
pass
return jsonify({
'accepted': True,
'peer_id': peer_id,
'server_config': server_config,
})
@app.route('/api/message/character', methods=['POST'])
def receive_character():
"""
Receive a single encrypted character from the connected client.
The character is validated and placed in _display_queue. The
_on_character_received callback is invoked immediately so the
character appears on the server's lampboard without delay.
The client does not need to poll to deliver characters to the
server; they are pushed directly via this endpoint.
"""
data = request.get_json(silent=True)
if not data:
return jsonify({'received': False, 'error': 'No JSON body'}), 400
with self._state_lock:
if not self._connected:
return jsonify({
'received': False,
'error': 'No peer connected',
}), 403
requesting_peer = data.get('peer_id')
if requesting_peer != self._peer_id:
return jsonify({
'received': False,
'error': 'Unknown peer ID',
}), 403
ciphertext = data.get('ciphertext', '')
if not ciphertext or len(ciphertext) != 1:
return jsonify({
'received': False,
'error': 'ciphertext must be exactly one character',
}), 400
char = ciphertext.upper()
if char not in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
return jsonify({
'received': False,
'error': 'ciphertext must be a letter A-Z',
}), 400
# Store in display queue (for logging/future use).
self._display_queue.put({
'ciphertext': char,
'timestamp': data.get('timestamp', time.time()),
})
# Invoke the callback immediately so the server GUI updates now.
if self._on_character_received:
try:
self._on_character_received(char)
except Exception as cb_err:
logger.warning(f"Character callback error: {cb_err}")
logger.info(f"Character received from client: '{char}'")
return jsonify({'received': True})
@app.route('/api/message/receive', methods=['GET'])
def poll_characters():
"""
Return all characters typed by the server operator since the last poll.
The client's polling loop calls this endpoint every
POLL_INTERVAL_MS milliseconds. All characters in _outgoing_queue
(placed there by server.send_character() when the server operator
presses a key) are returned in a single response and removed from
the queue. The client processes them in order and displays them
on the client's lampboard.
"""
characters = []
while not self._outgoing_queue.empty():
try:
item = self._outgoing_queue.get_nowait()
characters.append(item)
except queue.Empty:
break
return jsonify({
'characters': characters,
'count': len(characters),
})
@app.route('/api/disconnect', methods=['POST'])
def disconnect_peer():
"""
Handle a disconnection request from the connected peer.
Clears the connection state and drains both message queues,
allowing a new peer to connect. Using POST rather than DELETE
avoids compatibility issues with HTTP DELETE bodies across
different network intermediaries and client libraries.
"""
with self._state_lock:
peer_id = self._peer_id
self._peer_id = None
self._peer_config = None
self._connected = False
# Drain both queues.
for q in (self._display_queue, self._outgoing_queue):
while not q.empty():
try:
q.get_nowait()
except queue.Empty:
break
logger.info(f"Peer disconnected: {peer_id}")
return jsonify({'disconnected': True})
def start(self):
"""
Start the Flask server in a background daemon thread.
The server runs as a daemon thread, which means it automatically
stops when the main application exits. use_reloader=False is
essential when running Flask in a thread, as the reloader would
attempt to spawn a child process and interfere with the GUI.
Note: For production deployment, replace Werkzeug's built-in server
with a production WSGI server such as Gunicorn or Waitress.
"""
if self._running:
logger.warning("Server is already running")
return
self._running = True
def run_server():
logger.info(
f"Starting Enigma REST server on {self.host}:{self.port}"
)
self._app.run(
host=self.host,
port=self.port,
debug=False,
use_reloader=False,
threaded=True,
)
self._server_thread = threading.Thread(
target=run_server,
name='EnigmaServerThread',
daemon=True,
)
self._server_thread.start()
# Allow the server a moment to bind to the port before returning.
time.sleep(0.8)
logger.info("Enigma server started successfully")
def stop(self):
"""
Signal the server to stop.
Flask's built-in Werkzeug server does not provide a clean shutdown
mechanism when running in a daemon thread. Setting _running to False
signals intent; the thread will terminate when the main process exits
because it is a daemon thread.
"""
self._running = False
logger.info("Enigma server stop requested")
@property
def is_connected(self):
"""Whether a peer is currently connected to this server."""
with self._state_lock:
return self._connected
@property
def peer_config(self):
"""The machine configuration received from the connected peer."""
with self._state_lock:
return self._peer_config
Now the client. The most important design decision here is the use of two separate requests.Session objects: one for sending characters and one for the polling loop. A single Session is not thread-safe when used from multiple threads simultaneously, because the session's internal state (cookies, connection pool) can be corrupted by concurrent access. Using two sessions eliminates this race condition entirely.
# enigma_client.py
#
# The REST client for connecting to a remote Enigma server.
# Implements the client side of the peer-to-peer Enigma communication
# protocol defined in enigma_server.py.
#
# BIDIRECTIONAL DESIGN:
# Sending: The client sends characters typed by the local operator
# via POST /api/message/character to the server.
# Receiving: The client polls GET /api/message/receive every
# POLL_INTERVAL_MS milliseconds to retrieve characters
# typed by the server operator.
#
# Thread safety: two separate requests.Session objects are used —
# one for the polling loop thread and one for the send thread —
# to avoid concurrent access to a single session's internal state.
#
# Dependencies:
# pip install requests
import requests
import threading
import time
import uuid
import logging
logger = logging.getLogger(__name__)
class EnigmaClient:
"""
REST client for connecting to a remote Enigma server.
Manages the client side of a peer-to-peer Enigma connection.
Handles connection establishment, character transmission, and
polling for incoming characters typed by the server operator.
The client runs a background polling thread that periodically
calls GET /api/message/receive to check for characters the server
operator has typed. When characters are received, the registered
callback is invoked to update the GUI. The callback is always
invoked from the polling thread; the GUI must use root.after()
to safely update Tkinter widgets from it.
Two separate requests.Session objects are maintained: one for the
polling loop and one for sending characters. This avoids thread-safety
issues that arise from concurrent access to a single Session.
"""
# How often to poll the server for incoming characters (milliseconds).
POLL_INTERVAL_MS = 200
def __init__(self, server_url, local_config):
"""
Initialize the Enigma client.
Args:
server_url: The base URL of the Enigma server,
e.g. 'http://192.168.1.100:5000'.
Trailing slashes are stripped automatically.
local_config: The local machine's configuration dictionary,
as returned by EnigmaMachine.get_state().
"""
self.server_url = server_url.rstrip('/')
self.local_config = local_config
# A unique identifier for this client session.
self._peer_id = str(uuid.uuid4())
self._connected = False
self._server_config = None
# Callback invoked by the polling loop when a character arrives.
self._on_character_received = None
self._polling_thread = None
self._polling_active = False
# Separate sessions for sending and polling to ensure thread safety.
common_headers = {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'EnigmaMachineSimulator/1.0',
}
self._send_session = requests.Session()
self._send_session.headers.update(common_headers)
self._poll_session = requests.Session()
self._poll_session.headers.update(common_headers)
# Request timeout in seconds.
self._timeout = 5.0
def set_character_callback(self, callback):
"""
Set the callback function for characters received from the server operator.
The callback is invoked from the polling thread each time the server
returns one or more characters from its outgoing queue. The GUI must
use root.after() to safely update Tkinter widgets in response:
client.set_character_callback(
lambda c: root.after(0, lambda ch=c: gui._receive_character(ch))
)
Args:
callback: A callable that accepts a single uppercase letter string.
"""
self._on_character_received = callback
def connect(self):
"""
Establish a connection to the Enigma server.
Sends a POST request to /api/connect with the local machine
configuration. The server responds with its own configuration,
which can be compared using verify_configuration_match().
Returns:
A tuple (success, message) where success is a boolean and
message is a human-readable status string describing the outcome.
"""
try:
# First verify the server is reachable and not already occupied.
status_response = self._send_session.get(
f"{self.server_url}/api/status",
timeout=self._timeout,
)
status_response.raise_for_status()
status_data = status_response.json()
if status_data.get('connected'):
return False, "Server already has a connected peer"
# Send the connection request with our configuration.
connect_response = self._send_session.post(
f"{self.server_url}/api/connect",
json={
'peer_id': self._peer_id,
'config': self.local_config,
},
timeout=self._timeout,
)
connect_response.raise_for_status()
connect_data = connect_response.json()
if not connect_data.get('accepted'):
error = connect_data.get('error', 'Connection rejected by server')
return False, error
self._server_config = connect_data.get('server_config', {})
self._connected = True
# Start the polling thread to receive characters from the server.
self._start_polling()
logger.info(f"Connected to Enigma server at {self.server_url}")
return True, "Connected successfully"
except requests.ConnectionError:
return False, f"Cannot reach server at {self.server_url}"
except requests.Timeout:
return False, "Connection timed out"
except requests.HTTPError as e:
return False, f"HTTP error: {e.response.status_code}"
except Exception as e:
return False, f"Unexpected error: {str(e)}"
def send_character(self, plaintext_letter, ciphertext_letter):
"""
Send an encrypted character to the connected server.
Called from a background thread (spawned by the GUI's key press
handler) to avoid blocking the Tkinter event loop. Uses the
dedicated _send_session to avoid thread-safety conflicts with
the polling loop's _poll_session.
Args:
plaintext_letter: The original letter pressed (for logging).
ciphertext_letter: The encrypted output letter to transmit.
Returns:
True if the character was sent and acknowledged, False otherwise.
"""
if not self._connected:
logger.warning("Attempted to send character while not connected")
return False
try:
response = self._send_session.post(
f"{self.server_url}/api/message/character",
json={
'peer_id': self._peer_id,
'ciphertext': ciphertext_letter,
'timestamp': time.time(),
},
timeout=self._timeout,
)
response.raise_for_status()
data = response.json()
if data.get('received'):
logger.debug(
f"Sent: '{plaintext_letter}' -> '{ciphertext_letter}'"
)
return True
else:
logger.error(f"Server rejected character: {data.get('error')}")
return False
except requests.RequestException as e:
logger.error(f"Failed to send character: {e}")
self._connected = False
return False
def _start_polling(self):
"""
Start the background thread that polls for characters from the server.
The polling thread runs until the connection is closed or the
application exits. It calls GET /api/message/receive every
POLL_INTERVAL_MS milliseconds to retrieve characters the server
operator has typed.
"""
self._polling_active = True
self._polling_thread = threading.Thread(
target=self._poll_loop,
name='EnigmaPollingThread',
daemon=True,
)
self._polling_thread.start()
logger.info("Character polling thread started")
def _poll_loop(self):
"""
The main loop of the polling thread.
Continuously polls GET /api/message/receive on the server. When
characters are returned (characters the server operator typed), the
registered callback is invoked for each one. The callback is the
sole mechanism for delivering server-operator characters to the
client's GUI.
A polling interval of POLL_INTERVAL_MS (200ms) provides responsive
message reception while keeping network traffic minimal. On a local
network this results in approximately 5 HTTP requests per second.
"""
poll_interval = self.POLL_INTERVAL_MS / 1000.0
while self._polling_active and self._connected:
try:
response = self._poll_session.get(
f"{self.server_url}/api/message/receive",
timeout=self._timeout,
)
response.raise_for_status()
data = response.json()
for char_data in data.get('characters', []):
ciphertext = char_data.get('ciphertext', '')
if ciphertext and self._on_character_received:
self._on_character_received(ciphertext)
except requests.ConnectionError:
logger.warning("Lost connection to Enigma server")
self._connected = False
break
except requests.RequestException as e:
# Log transient errors but keep polling; don't break the loop.
logger.warning(f"Polling error (will retry): {e}")
time.sleep(poll_interval)
logger.info("Character polling thread stopped")
def disconnect(self):
"""
Disconnect from the Enigma server cleanly.
Stops the polling thread, sends a POST to /api/disconnect to notify
the server, and closes both HTTP sessions. Safe to call even if the
connection was already lost.
"""
self._polling_active = False
self._connected = False
try:
self._send_session.post(
f"{self.server_url}/api/disconnect",
json={'peer_id': self._peer_id},
timeout=self._timeout,
)
logger.info("Disconnected from Enigma server")
except requests.RequestException as e:
logger.warning(f"Error during disconnect (server may be gone): {e}")
finally:
self._send_session.close()
self._poll_session.close()
def verify_configuration_match(self):
"""
Check whether the local and remote machine configurations match.
This is a critical step after connecting: both machines must have
identical rotor selections, ring settings, initial positions, and
plugboard configurations to communicate correctly. A mismatch means
the machines will produce different ciphertext for the same input,
and decryption will fail.
Returns:
A tuple (match, differences) where match is True if all settings
match, and differences is a list of human-readable discrepancy
descriptions if they do not match.
"""
if not self._server_config:
return False, ["No server configuration received"]
differences = []
local_rotors = self.local_config.get('rotors', [])
server_rotors = self._server_config.get('rotors', [])
if local_rotors != server_rotors:
differences.append(
f"Rotors: local={local_rotors}, remote={server_rotors}"
)
local_ref = self.local_config.get('reflector')
server_ref = self._server_config.get('reflector')
if local_ref != server_ref:
differences.append(
f"Reflector: local={local_ref}, remote={server_ref}"
)
local_ring = self.local_config.get('ring_settings')
server_ring = self._server_config.get('ring_settings')
if local_ring != server_ring:
differences.append(
f"Ring settings: local={local_ring}, remote={server_ring}"
)
local_pos = self.local_config.get('current_positions')
server_pos = self._server_config.get('current_positions')
if local_pos != server_pos:
differences.append(
f"Initial positions: local={local_pos}, remote={server_pos}"
)
# Compare plugboard pairs as sets (order-independent).
local_pairs = set(self.local_config.get('plugboard_pairs', []))
server_pairs = set(self._server_config.get('plugboard_pairs', []))
if local_pairs != server_pairs:
differences.append(
f"Plugboard: local={sorted(local_pairs)}, "
f"remote={sorted(server_pairs)}"
)
return len(differences) == 0, differences
@property
def is_connected(self):
"""Whether the client is currently connected to a server."""
return self._connected
@property
def server_config(self):
"""The machine configuration received from the server."""
return self._server_config
CHAPTER SEVEN: THE MAIN APPLICATION ENTRY POINT
With all components in place, we need a main entry point that ties everything together. This file handles command-line arguments, initializes the application, and starts the Tkinter event loop.
# main.py
#
# Main entry point for the Enigma Machine Simulator.
#
# Usage:
# python main.py Start with default settings (server port 5000)
# python main.py --port 5001 Start with server port 5001
# python main.py --test Run cryptographic self-tests and exit
# python main.py --headless Run self-tests only, no GUI (for CI/CD)
#
# Requirements (install before running):
# pip install flask flask-cors requests
#
# Standard library dependencies (no installation needed):
# tkinter, threading, queue, uuid, time, logging, math, argparse, socket
import sys
import argparse
import tkinter as tk
def parse_arguments():
"""
Parse command-line arguments for the Enigma simulator.
Returns:
An argparse.Namespace object with the parsed arguments.
"""
parser = argparse.ArgumentParser(
description='Enigma Machine Simulator — Wehrmacht Model',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Examples:\n"
" python main.py Start the simulator\n"
" python main.py --port 5001 Use port 5001 for the server\n"
" python main.py --test Run self-tests and exit\n"
" python main.py --headless Headless self-test mode\n"
),
)
parser.add_argument(
'--port',
type=int,
default=5000,
metavar='PORT',
help='TCP port for the network server when started (default: 5000)',
)
parser.add_argument(
'--test',
action='store_true',
help='Run cryptographic self-tests and exit with code 0 (pass) or 1 (fail)',
)
parser.add_argument(
'--headless',
action='store_true',
help='Run in headless mode: execute self-tests only, no GUI',
)
return parser.parse_args()
def run_tests():
"""
Run the cryptographic self-tests and report results.
Returns:
0 if all tests pass, 1 if any test fails.
"""
print("=" * 60)
print("ENIGMA MACHINE CRYPTOGRAPHIC SELF-TEST")
print("=" * 60)
try:
from enigma_engine import run_self_test
run_self_test()
print("\nAll tests PASSED. The cryptographic engine is correct.")
return 0
except AssertionError as e:
print(f"\nTest FAILED: {e}")
return 1
except Exception as e:
print(f"\nUnexpected error during testing: {e}")
import traceback
traceback.print_exc()
return 1
def main():
"""
Main entry point for the Enigma Machine Simulator.
Parses command-line arguments, optionally runs self-tests, and then
launches the graphical interface. Registers a window close handler
that cleanly shuts down any active network connections before exit.
"""
args = parse_arguments()
if args.test or args.headless:
sys.exit(run_tests())
# Verify that tkinter is available before attempting to create the window.
try:
root = tk.Tk()
except tk.TclError as e:
print(f"Cannot create GUI window: {e}")
print("Try running with --headless for a no-GUI self-test.")
sys.exit(1)
# Attempt to set a window icon; fail silently if the file is absent.
try:
root.iconbitmap('assets/enigma_icon.ico')
except Exception:
pass
from enigma_gui import EnigmaGUI
app = EnigmaGUI(root)
# Pass the configured server port to the GUI for use in the network dialog.
app._default_server_port = args.port
def on_closing():
"""
Clean up network connections and server before closing the window.
Called when the user clicks the window's close button (X). Ensures
that the peer is notified of the disconnection and the server thread
is signalled to stop before the Tkinter event loop exits.
"""
if app._enigma_server is not None:
app._enigma_server.stop()
if app.network_client is not None and app.network_client.is_connected:
import threading
t = threading.Thread(
target=app.network_client.disconnect,
daemon=True,
)
t.start()
t.join(timeout=2.0) # Wait at most 2 seconds for clean disconnect
root.destroy()
root.protocol("WM_DELETE_WINDOW", on_closing)
print("Enigma Machine Simulator started.")
print(
f"Network server will use port {args.port} "
f"when started via the Network button."
)
print("Close the window or press Ctrl+C to quit.")
try:
root.mainloop()
except KeyboardInterrupt:
on_closing()
if __name__ == '__main__':
main()
CHAPTER EIGHT: THE FOUR-ROTOR NAVAL ENIGMA (M4)
The German Navy (Kriegsmarine) used a four-rotor Enigma machine from February 1942 onward, designated M4. The fourth rotor was a thin rotor that fit between the reflector and the three standard rotors. The M4 used two special thin rotors (Beta and Gamma) and two special thin reflectors (Bruno and Caesar). The fourth rotor never steps automatically; it is set manually before each message, functioning more like an additional ring setting than a true rotor.
Adding four-rotor support requires only modest changes to the EnigmaMachine class. The _step_rotors method remains identical (the fourth rotor never steps), and encrypt_letter needs one additional forward pass and one additional backward pass through the fourth rotor. The following class extends EnigmaMachine cleanly without modifying the base class.
# enigma_m4.py
#
# The four-rotor Enigma M4 used by the German Navy (Kriegsmarine).
# Extends EnigmaMachine with a fourth non-stepping rotor and the
# special thin reflectors used with the M4.
#
# Thin rotor wirings sourced from the Crypto Museum (cryptomuseum.com).
from enigma_engine import (
EnigmaMachine,
Rotor,
Reflector,
Plugboard,
ROTOR_WIRINGS,
ALPHABET,
letter_to_index,
index_to_letter,
)
# Thin rotors for the M4 (never step; set manually like a ring setting).
THIN_ROTOR_WIRINGS = {
'Beta': ('LEYJVCNIXWPBQMDRTAKZGFUHOS', None),
'Gamma': ('FSOKANUERHMBTIYCWLQPZXVGJD', None),
}
# Thin reflectors used with the M4 (replace the standard B and C reflectors).
THIN_REFLECTOR_WIRINGS = {
'Bruno': 'ENKQAUYWJICOPBLMDXZVFTHRGS',
'Caesar': 'RDOBJNTKVEHMLFCWZAXGYIPSUQ',
}
# Combined lookup for all rotor types (standard + thin).
ALL_ROTOR_WIRINGS = {**ROTOR_WIRINGS, **THIN_ROTOR_WIRINGS}
class FourRotorEnigmaMachine(EnigmaMachine):
"""
The four-rotor Enigma M4 used by the German Navy.
The fourth rotor (Zusatzwalze) sits between the thin reflector and
the three standard rotors. It never steps automatically; it is set
manually before each message, like an additional ring setting.
The thin reflectors Bruno and Caesar are used with the M4 instead
of the standard Reflectors B and C. The standard rotors I-V are
used for the three stepping positions.
Signal path (forward):
plugboard -> right -> middle -> left -> fourth -> thin_reflector
Signal path (backward):
thin_reflector -> fourth (backward) -> left -> middle -> right -> plugboard
"""
def __init__(self, rotors, reflector, plugboard, fourth_rotor):
"""
Initialize the four-rotor Enigma M4.
Args:
rotors: A list of exactly three standard Rotor objects,
ordered [left, middle, right].
reflector: A thin Reflector object (Bruno or Caesar).
plugboard: A Plugboard object.
fourth_rotor: A thin Rotor object (Beta or Gamma) that sits
between the left rotor and the thin reflector.
This rotor never steps automatically.
"""
super().__init__(rotors, reflector, plugboard)
self._fourth_rotor = fourth_rotor
@classmethod
def from_config_m4(cls, config):
"""
Factory method to create a FourRotorEnigmaMachine from a config dict.
Args:
config: A dictionary with the following keys:
'rotors' - List of 3 standard rotor names.
'fourth_rotor' - Name of the thin rotor: 'Beta' or 'Gamma'.
'reflector' - Thin reflector name: 'Bruno' or 'Caesar'.
'ring_settings' - 4-letter string (index 0 = fourth rotor ring,
indices 1-3 = left, middle, right ring).
'initial_positions' - 4-letter string (same ordering as ring_settings).
'plugboard_pairs' - List of pair strings.
Returns:
A fully configured FourRotorEnigmaMachine instance.
Raises:
ValueError: If any configuration value is invalid.
"""
rotor_names = config.get('rotors', ['I', 'II', 'III'])
fourth_name = config.get('fourth_rotor', 'Beta')
reflector_name = config.get('reflector', 'Bruno')
ring_settings = config.get('ring_settings', 'AAAA')
initial_positions = config.get('initial_positions', 'AAAA')
plugboard_pairs = config.get('plugboard_pairs', [])
if len(ring_settings) != 4 or len(initial_positions) != 4:
raise ValueError(
"M4 requires 4-character ring_settings and initial_positions "
"(index 0 = fourth rotor, indices 1-3 = left/middle/right)"
)
# Build the three standard stepping rotors (positions 1-3 of settings).
rotors = []
for i, name in enumerate(rotor_names):
if name not in ROTOR_WIRINGS:
raise ValueError(f"Unknown standard rotor: '{name}'")
wiring, notch = ROTOR_WIRINGS[name]
ring = letter_to_index(ring_settings[i + 1].upper())
offset = letter_to_index(initial_positions[i + 1].upper())
rotors.append(Rotor(name, wiring, notch, ring, offset))
# Build the fourth (non-stepping) thin rotor (position 0 of settings).
if fourth_name not in THIN_ROTOR_WIRINGS:
raise ValueError(
f"Unknown thin rotor: '{fourth_name}'. "
f"Must be 'Beta' or 'Gamma'."
)
thin_wiring, _ = THIN_ROTOR_WIRINGS[fourth_name]
fourth_ring = letter_to_index(ring_settings[0].upper())
fourth_offset = letter_to_index(initial_positions[0].upper())
# Notch 'A' is a placeholder; the fourth rotor never steps.
fourth_rotor = Rotor(
fourth_name, thin_wiring, 'A',
fourth_ring, fourth_offset,
)
# Build the thin reflector.
if reflector_name not in THIN_REFLECTOR_WIRINGS:
raise ValueError(
f"Unknown thin reflector: '{reflector_name}'. "
f"Must be 'Bruno' or 'Caesar'."
)
reflector = Reflector(
reflector_name,
THIN_REFLECTOR_WIRINGS[reflector_name],
)
plugboard = Plugboard(plugboard_pairs if plugboard_pairs else None)
return cls(rotors, reflector, plugboard, fourth_rotor)
def encrypt_letter(self, letter):
"""
Encrypt a single letter through the four-rotor Enigma M4.
The signal path is extended to include the fourth rotor between
the left standard rotor and the thin reflector. The fourth rotor
never steps; only the three standard rotors step.
Args:
letter: A single letter (A-Z, case insensitive).
Returns:
The encrypted letter as an uppercase string.
"""
letter = letter.upper()
if letter not in ALPHABET:
return letter
# Step only the three standard rotors; the fourth rotor never steps.
self._step_rotors()
signal = letter_to_index(letter)
# Forward through plugboard and three standard rotors (right to left).
signal = self._plugboard.forward(signal)
signal = self._rotors[2].forward(signal) # Right rotor
signal = self._rotors[1].forward(signal) # Middle rotor
signal = self._rotors[0].forward(signal) # Left rotor
# Forward through the fourth thin rotor.
signal = self._fourth_rotor.forward(signal)
# Through the thin reflector.
signal = self._reflector.reflect(signal)
# Backward through the fourth thin rotor.
signal = self._fourth_rotor.backward(signal)
# Backward through the three standard rotors (left to right).
signal = self._rotors[0].backward(signal) # Left rotor
signal = self._rotors[1].backward(signal) # Middle rotor
signal = self._rotors[2].backward(signal) # Right rotor
# Through the plugboard.
signal = self._plugboard.backward(signal)
return index_to_letter(signal)
CHAPTER NINE: INSTALLATION, DEPLOYMENT, AND OPERATION
9.1 Prerequisites and Installation
The application requires Python 3.8 or later. Python's tkinter module is included with standard Python installations on Windows and macOS. On Linux, it may need to be installed separately. On Debian and Ubuntu systems, the command is:
sudo apt-get install python3-tk
On Fedora and Red Hat systems, the command is:
sudo dnf install python3-tkinter
The external Python dependencies are Flask (the web framework for the REST server), flask-cors (Cross-Origin Resource Sharing support for Flask), and requests (the HTTP client library). The complete requirements.txt file is:
flask>=2.3.0
flask-cors>=4.0.0
requests>=2.31.0
To install all dependencies, run the following command in the project directory:
pip install -r requirements.txt
9.2 Project File Structure
All Python files and the requirements.txt file should be placed in the same directory. The optional assets directory holds the application icon.
enigma_simulator/
|
+-- main.py Entry point; argument parsing; application startup
+-- enigma_engine.py Cryptographic engine; all cipher classes; self-tests
+-- enigma_gui.py Graphical interface; EnigmaGUI class; animations
+-- enigma_server.py Flask REST server; EnigmaServer class
+-- enigma_client.py REST client; EnigmaClient class
+-- enigma_m4.py Four-rotor Naval Enigma M4 extension
+-- enigma_indicator.py Historical message indicator procedure (optional)
+-- requirements.txt External Python package dependencies
+-- assets/
+-- enigma_icon.ico Application icon (optional; absence is handled)
9.3 Running the Application
To start the simulator with default settings, navigate to the project directory and run:
python main.py
To start with a specific server port (useful if port 5000 is already in use on your machine):
python main.py --port 8080
To run the cryptographic self-tests and verify the engine is correct before using the application:
python main.py --test
To run the self-tests without a GUI (useful in server environments or CI/CD pipelines):
python main.py --headless
9.4 Setting Up a Networked Session
To use the networked Enigma, two operators follow a procedure that closely mirrors the historical Enigma operating procedure. The key difference is that instead of consulting a physical key book (Schluesselbuch), the operators agree on settings through a secure out-of-band channel before starting their session.
Operator Alice starts the application and opens the Settings dialog by clicking the Settings button. She selects her rotors (for example, Rotor III in the left position, Rotor I in the middle, and Rotor IV in the right), sets the reflector to B, configures the ring settings to MCK, and sets the initial rotor positions to XYZ. She also configures her plugboard with cables connecting the pairs AB, CD, EF, GH, and IJ. She then opens the Network dialog, selects the Start Server tab, and clicks Start Server on port 5000. Her status indicator changes to LISTENING (yellow).
Operator Bob starts his own application and configures it with exactly the same settings: rotors III, I, IV from left to right, reflector B, ring settings MCK, initial positions XYZ, and the same plugboard pairs. He then opens the Network dialog, selects the Connect to Server tab, enters Alice's IP address as "http://192.168.1.100:5000" (substituting Alice's actual IP address), and clicks Connect. The application connects to Alice's server and automatically compares the configurations. If they match, both operators see the CONNECTED status indicator turn green.
Now Alice and Bob can communicate bidirectionally. When Alice presses the letter H on her keyboard, her Enigma encrypts it (producing, say, the letter Q). The letter Q is placed in Alice's server outgoing queue. Bob's polling loop retrieves Q from Alice's server within 200 milliseconds and illuminates the Q lamp on Bob's lampboard. When Bob presses Q on his keyboard, his Enigma decrypts it back to H, because the Enigma is its own inverse when both machines use identical settings. Simultaneously, when Bob presses a key, the encrypted character is sent directly to Alice's server via POST /api/message/character, and Alice's lampboard illuminates immediately without polling delay.
9.5 Firewall and Network Considerations
For two machines on the same local area network, no special firewall configuration is typically needed, as local network traffic is usually unrestricted. For connections across the internet, the machine running the server must have its chosen port (default 5000) forwarded through its router's NAT configuration to the server machine's local IP address. The operator running the server should provide their public IP address (visible at a service such as whatismyip.com) to the connecting operator.
For corporate networks with strict firewall policies, using port 80 or port 443 (standard HTTP and HTTPS ports) may be necessary to avoid being blocked. The server port is configurable via the --port command-line argument and the port field in the Network dialog.
CHAPTER TEN: TESTING AND VERIFICATION
10.1 The Self-Test Suite
The cryptographic engine includes four self-tests that verify different aspects of correctness. These tests are integrated into enigma_engine.py and can be run at any time with "python main.py --test". The tests verify the absolute output against a known test vector, the involution property (decrypt(encrypt(x)) == x), the constraint that no letter encrypts to itself, and the correct behavior of the double-stepping anomaly.
A more comprehensive test file can be placed in a tests/ subdirectory. The following example demonstrates how to structure unit tests using Python's built-in unittest framework.
# tests/test_engine.py
#
# Unit tests for the Enigma cryptographic engine.
# Run with: python -m pytest tests/ -v
# or: python -m unittest tests.test_engine -v
import unittest
import sys
import os
# Add the parent directory to the path so we can import enigma_engine.
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from enigma_engine import (
EnigmaMachine,
Plugboard,
Rotor,
Reflector,
ALPHABET,
letter_to_index,
index_to_letter,
ROTOR_WIRINGS,
REFLECTOR_WIRINGS,
)
class TestPlugboard(unittest.TestCase):
"""Tests for the Plugboard class."""
def test_identity_with_no_pairs(self):
"""An empty plugboard maps every letter to itself."""
pb = Plugboard()
for i in range(26):
self.assertEqual(pb.forward(i), i)
def test_single_pair_swap(self):
"""A single cable correctly swaps two letters bidirectionally."""
pb = Plugboard(['AB'])
self.assertEqual(pb.forward(0), 1) # A -> B
self.assertEqual(pb.forward(1), 0) # B -> A
self.assertEqual(pb.forward(2), 2) # C -> C (unchanged)
def test_involution_property(self):
"""Applying the plugboard twice returns the original signal."""
pb = Plugboard(['AB', 'CD', 'EF'])
for i in range(26):
self.assertEqual(pb.forward(pb.forward(i)), i)
def test_duplicate_letter_raises(self):
"""Using a letter in two pairs raises ValueError."""
with self.assertRaises(ValueError):
Plugboard(['AB', 'AC'])
def test_self_pair_raises(self):
"""Connecting a letter to itself raises ValueError."""
with self.assertRaises(ValueError):
Plugboard(['AA'])
def test_too_many_pairs_raises(self):
"""More than 13 pairs raises ValueError."""
pairs = [chr(65 + i * 2) + chr(65 + i * 2 + 1) for i in range(14)]
with self.assertRaises(ValueError):
Plugboard(pairs)
class TestRotor(unittest.TestCase):
"""Tests for the Rotor class."""
def setUp(self):
"""Create a standard Rotor I for testing."""
wiring, notch = ROTOR_WIRINGS['I']
self.rotor = Rotor('I', wiring, notch, ring_setting=0, offset=0)
def test_forward_backward_inverse(self):
"""forward followed by backward returns the original signal."""
for i in range(26):
self.assertEqual(
self.rotor.backward(self.rotor.forward(i)), i,
f"forward/backward not inverse at index {i}"
)
def test_step_advances_offset(self):
"""step() increments the offset by one."""
initial = self.rotor.offset
self.rotor.step()
self.assertEqual(self.rotor.offset, (initial + 1) % 26)
def test_step_wraps_at_z(self):
"""step() wraps from Z (25) back to A (0)."""
self.rotor.offset = 25
self.rotor.step()
self.assertEqual(self.rotor.offset, 0)
def test_notch_detection(self):
"""is_at_notch() returns True only when at the notch position."""
_, notch = ROTOR_WIRINGS['I']
notch_idx = letter_to_index(notch)
self.rotor.offset = notch_idx
self.assertTrue(self.rotor.is_at_notch())
self.rotor.offset = (notch_idx + 1) % 26
self.assertFalse(self.rotor.is_at_notch())
class TestEnigmaMachine(unittest.TestCase):
"""Tests for the EnigmaMachine class."""
def _make_machine(self, positions='AAA', pairs=None):
"""Helper to create a standard test machine."""
return EnigmaMachine.from_config({
'rotors': ['I', 'II', 'III'],
'reflector': 'B',
'ring_settings': 'AAA',
'initial_positions': positions,
'plugboard_pairs': pairs or [],
})
def test_known_vector(self):
"""Encrypting AAAAA at AAA must produce BDZGO."""
machine = self._make_machine()
self.assertEqual(machine.encrypt_string('AAAAA'), 'BDZGO')
def test_involution(self):
"""Encrypting ciphertext with same settings recovers plaintext."""
machine1 = self._make_machine()
ciphertext = machine1.encrypt_string('HELLOWORLD')
machine2 = self._make_machine()
plaintext = machine2.encrypt_string(ciphertext)
self.assertEqual(plaintext, 'HELLOWORLD')
def test_no_self_encryption(self):
"""No letter should ever encrypt to itself."""
machine = self._make_machine()
plaintext = ALPHABET * 10
ciphertext = machine.encrypt_string(plaintext)
for p, c in zip(plaintext, ciphertext):
self.assertNotEqual(p, c, f"Letter {p} encrypted to itself")
def test_double_stepping_anomaly(self):
"""The double-stepping anomaly must produce KMV -> LNW -> LNX."""
machine = self._make_machine(positions='KMV')
self.assertEqual(machine.rotor_display, 'KMV')
machine.encrypt_letter('A')
self.assertEqual(machine.rotor_display, 'LNW')
machine.encrypt_letter('A')
self.assertEqual(machine.rotor_display, 'LNX')
def test_non_letter_passthrough(self):
"""Non-letter characters must pass through unchanged."""
machine = self._make_machine()
self.assertEqual(machine.encrypt_letter(' '), ' ')
self.assertEqual(machine.encrypt_letter('3'), '3')
self.assertEqual(machine.encrypt_letter('.'), '.')
def test_reset_restores_position(self):
"""reset() must restore the initial rotor positions."""
machine = self._make_machine(positions='XYZ')
machine.encrypt_string('HELLO')
self.assertNotEqual(machine.rotor_display, 'XYZ')
machine.reset()
self.assertEqual(machine.rotor_display, 'XYZ')
def test_plugboard_affects_output(self):
"""A plugboard configuration must change the encrypted output."""
machine_no_plug = self._make_machine()
machine_with_plug = self._make_machine(pairs=['AB'])
result_no_plug = machine_no_plug.encrypt_string('HELLO')
result_with_plug = machine_with_plug.encrypt_string('HELLO')
self.assertNotEqual(result_no_plug, result_with_plug)
def test_case_insensitive_input(self):
"""Lowercase input must produce the same output as uppercase."""
machine1 = self._make_machine()
machine2 = self._make_machine()
self.assertEqual(
machine1.encrypt_string('hello'),
machine2.encrypt_string('HELLO'),
)
if __name__ == '__main__':
unittest.main(verbosity=2)
10.2 Performance Considerations
The cryptographic engine is extremely fast. Encrypting a single character involves only a handful of array lookups and modular arithmetic operations, all on 26-element arrays. Even on modest hardware, the engine can encrypt millions of characters per second. The performance bottleneck in the application is the GUI animation system, not the cryptographic engine.
The Tkinter animation system uses the after() method to schedule callbacks. Each keypress schedules several callbacks: one for the key release, one for the lamp fade start, and ANIM_LAMP_FADE_STEPS plus one callbacks for the individual fade steps. If the user types very quickly, many callbacks can be queued simultaneously. The implementation handles this by canceling pending callbacks when a new animation for the same element is triggered, preventing visual artifacts from overlapping animations.
The networking layer introduces latency that depends on the network quality. On a local area network, the round-trip time for a REST request is typically 1 to 5 milliseconds, which is imperceptible. Over the internet, latency can be 50 to 200 milliseconds or more, which means there will be a noticeable delay between pressing a key and seeing the character appear on the peer's screen. This is actually historically accurate: radio operators experienced similar delays due to transmission time and the need to manually transcribe messages.
10.3 Security Considerations
It is important to emphasize that the Enigma cipher is not secure by modern standards. The historical Enigma was broken by the Bletchley Park codebreakers using mathematical techniques and mechanical computing. Modern cryptanalysis can break Enigma configurations almost instantly with a personal computer. Our application is an educational and historical simulation, not a secure communication tool. The REST API transmits data over unencrypted HTTP.
For a real secure communication application, you would use HTTPS with TLS certificates and replace the Enigma cipher with a modern algorithm such as AES-256-GCM or ChaCha20-Poly1305. If you want to add basic transport security to the REST API for demonstration purposes, Flask supports SSL directly. After generating a self-signed certificate with the OpenSSL command:
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
you can modify the server's start() method to pass ssl_context=('cert.pem', 'key.pem') to app.run(). The client would then need to use "https://" URLs and pass verify=False to requests calls (or provide the certificate path for proper verification).
CHAPTER ELEVEN: THE MESSAGE INDICATOR SYSTEM
Historical Enigma operators used a two-part message key system. The operator first encrypted a three-letter message key (chosen randomly) using the day's ground settings (Grundstellung), then reset the rotors to the message key and encrypted the actual message. The recipient would decrypt the message key using the ground settings, set their rotors to the decrypted key, and then decrypt the message.
This system was one of the vulnerabilities that Bletchley Park exploited: early in the war, operators were required to encrypt the message key twice (as a six-letter indicator), and the repeated key provided a powerful crib. Our application implements this system as an optional module.
# enigma_indicator.py
#
# Implements the historical Enigma message indicator procedure.
# This module is optional and demonstrates the two-part key system
# used by Wehrmacht Enigma operators.
from enigma_engine import EnigmaMachine, ALPHABET
import random
class MessageIndicatorSystem:
"""
Implements the historical Enigma message indicator procedure.
The procedure works as follows. The operator sets the machine to the
day's ground settings (Grundstellung). The operator chooses a random
three-letter message key (Spruchschluessel), such as XYZ. The operator
encrypts the message key using the ground settings. The operator then
resets the machine to the message key (XYZ) and encrypts the actual
message. The transmitted message consists of the encrypted message key
followed by the encrypted message body.
The recipient sets their machine to the ground settings, decrypts the
first three letters to recover the message key, sets their machine to
the message key, and decrypts the message body.
This class manages the ground settings machine and provides methods
for creating and decoding message headers.
"""
def __init__(self, ground_config):
"""
Initialize the indicator system with the ground settings.
Args:
ground_config: A configuration dictionary for EnigmaMachine.from_config()
representing the day's ground settings.
"""
self._ground_config = ground_config
self._ground_machine = EnigmaMachine.from_config(ground_config)
def generate_message_key(self):
"""
Generate a random three-letter message key.
Returns:
A three-letter uppercase string, e.g. 'XYZ'.
"""
return ''.join(random.choice(ALPHABET) for _ in range(3))
def create_message_header(self, message_key):
"""
Create the encrypted message key header for transmission.
Resets the ground settings machine to its initial position and
encrypts the message key. The result is the three-letter header
that is transmitted before the encrypted message body.
Args:
message_key: A three-letter uppercase string, e.g. 'XYZ'.
Returns:
A three-letter encrypted message key string.
Raises:
ValueError: If message_key is not exactly 3 uppercase letters.
"""
if (len(message_key) != 3
or not all(c in ALPHABET for c in message_key.upper())):
raise ValueError(
f"Message key must be exactly 3 letters, got '{message_key}'"
)
# Always start from the ground settings initial position.
self._ground_machine = EnigmaMachine.from_config(self._ground_config)
encrypted_key = self._ground_machine.encrypt_string(message_key.upper())
return encrypted_key
def decode_message_header(self, encrypted_key):
"""
Decrypt a received message key header to recover the message key.
Resets the ground settings machine to its initial position and
decrypts the encrypted key. The result is the three-letter message
key that should be used to set up the machine for message decryption.
Args:
encrypted_key: The three-letter encrypted key from the message header.
Returns:
The decrypted three-letter message key.
Raises:
ValueError: If encrypted_key is not exactly 3 uppercase letters.
"""
if (len(encrypted_key) != 3
or not all(c in ALPHABET for c in encrypted_key.upper())):
raise ValueError(
f"Encrypted key must be exactly 3 letters, got '{encrypted_key}'"
)
# Always start from the ground settings initial position.
self._ground_machine = EnigmaMachine.from_config(self._ground_config)
message_key = self._ground_machine.encrypt_string(encrypted_key.upper())
return message_key
def create_message_machine(self, message_key):
"""
Create an EnigmaMachine configured with the message key as its
starting position, ready to encrypt or decrypt the message body.
Args:
message_key: The three-letter message key (decrypted from header).
Returns:
An EnigmaMachine instance set to the message key position.
"""
message_config = dict(self._ground_config)
message_config['initial_positions'] = message_key.upper()
return EnigmaMachine.from_config(message_config)
CONCLUSION: WHAT WE HAVE BUILT AND WHAT WE HAVE LEARNED
We have built a complete, faithful simulation of the German Enigma machine in Python, organized across seven production-ready Python files totaling well over a thousand lines of carefully documented code. The application implements every significant feature of the historical machine: the five Wehrmacht rotors with their correct wirings and notch positions, the two standard reflectors, the plugboard with up to 13 cable connections, the ring setting mechanism, and the historically accurate double-stepping anomaly of the rotor stepping mechanism.
The graphical interface renders the machine with a visual style inspired by the historical Enigma, with animated key presses, illuminated lamps with smooth color-interpolated fade effects, and rotor windows that flash and update as the rotors step. The networking layer uses a fully bidirectional REST API built with Flask to allow two operators to connect their virtual Enigma machines over a local network or the internet and exchange encrypted messages in real time. Characters typed by the client operator are pushed directly to the server's lampboard via POST requests, appearing with minimal delay. Characters typed by the server operator are placed in an outgoing queue and retrieved by the client's polling loop within 200 milliseconds, giving both operators a responsive real-time communication experience.
Along the way, we explored the mathematical foundations of the Enigma cipher: the theory of permutation groups, the composition of permutations, the involution property of the reflector, and the combinatorial explosion of possible configurations that made Enigma seem unbreakable. We saw how the ring setting is implemented as a conjugation of the rotor permutation, and how the offset and ring setting interact in the three-step forward and backward signal path calculations.
The most important lesson from this project is that cryptographic security is not just about complexity. The Enigma had an astronomically large key space, larger than many modern encryption systems of its era, but it was broken because of structural mathematical weaknesses: the involution property, the inability of a letter to encrypt to itself, and the predictable message formats used by operators under operational pressure. Modern cryptography has learned from these lessons and uses mathematical structures that do not have such exploitable properties.
The Enigma machine remains one of the most elegant and fascinating devices ever built. It is a mechanical computer that implements a sophisticated mathematical algorithm using nothing but gears, springs, and electrical contacts. Building a software simulation of it is not just an interesting programming exercise; it is a way of understanding and honoring one of the most consequential technological artifacts of the twentieth century, and of appreciating the extraordinary minds on both sides of the cryptographic struggle who shaped the course of history.