I recently wrote about the multiplication code used by WWII double agent Eddie Chapman, famously known as Agent Zigzag. While this cipher was brilliant for a human operative in the 1940s hiding from manual codebreakers, a modern laptop would tear through it in a fraction of a second.
Why the code is vulnerable
Here is why this cipher is highly vulnerable to modern computing, and how a computer would go about cracking it.
The key apace is tiny (brute-force attack)
In cryptography, the “key space” is the total number of possible keys. For a modern encryption standard like AES-256, the number of keys is astronomically large. For Agent Zigzag’s cipher, the key space is incredibly small.
If we assume the spy used a standard English dictionary word as the keyword:
- There are roughly 100,000 to 200,000 common English words.
- There are only 31 possible days of the month.
- Total combinations: 200,000 words × 31 days = 6.2 million possible keys.
A modern Python script can generate and test 6.2 million Gronsfeld keys in just a few seconds.
The shift is restricted (statistical analysis)
A standard Vigenère cipher shifts letters using the entire alphabet (A-Z, or a shift of 0 to 25). Chapman’s cipher is a variation of a Gronsfeld cipher, which only uses the digits 0 through 9.
This introduces a massive statistical flaw. If the original letter is “A”, the encrypted letter can only ever be A through J. It will never be a Z or a W. Because standard English relies heavily on certain letters (like E, T, A, O, I, N), a computer can quickly analyse the ciphertext frequencies to spot these restricted patterns.
The key repeats (Kasiski examination)
Because the generated shift key is relatively short (usually 5 to 8 digits) and repeated over and over, it creates recurring patterns in the ciphertext. Modern computers use techniques like the Index of Coincidence or Kasiski examination to easily guess the length of the shift key. Once the computer knows the key is exactly 6 digits long, cracking the cipher becomes trivial, even without knowing the keyword or the date.
How a modern computer cracks it automatically
If you gave a modern computer the ciphertext without the keyword or date, it would use a dictionary attack with a fitness function:
- Load a dictionary: The script loads a text file containing thousands of English words.
- Loop through possibilities: It sets up a loop: for every word in the dictionary, and for every number 1 through 31, generate the shift key and decrypt the message.
- Score the output (fitness function): Because the computer generates 6.2 million decryptions, it needs to know which one is the correct English plaintext. It scores each result by checking for common English patterns (called n-grams), like the frequent appearance of “TH”, “HE”, “IN”, and “ER”.
- Output the winner(s): The decryptions with the highest scores of English patterns are spit out as the cracked messages.
Below is a complete Python script to automatically crack the cipher by ranking the top five most likely results.
def process_keyword(keyword):
"""
Assigns numerical values to the keyword based on alphabetical order.
Returns the combined integer.
"""
# Sanitize keyword to uppercase letters only
keyword = "".join(c for c in keyword.upper() if c.isalpha())
# Create a list of tuples: (character, original_index)
indexed_chars = [(char, i) for i, char in enumerate(keyword)]
# Sort alphabetically. Python's sort is stable, meaning duplicates
# automatically stay in their original left-to-right order.
sorted_chars = sorted(indexed_chars)
# Reconstruct the sequence based on the original positions
base_seq = [0] * len(keyword)
for rank, (char, orig_index) in enumerate(sorted_chars, start=1):
base_seq[orig_index] = str(rank)
return int("".join(base_seq))
def generate_shift_key(keyword, day):
"""
Multiplies the base sequence by the day of the month.
"""
base_int = process_keyword(keyword)
shift_key_int = base_int * day
return str(shift_key_int)
def agent_zigzag_decrypt(keyword, day, ciphertext):
"""
Decrypts the message by reversing the shift.
"""
shift_key_str = generate_shift_key(keyword, day)
# Remove any spaces from transmission blocks
ciphertext = ciphertext.replace(" ", "")
plaintext = ""
key_len = len(shift_key_str)
# Decrypt by shifting backward
for i, char in enumerate(ciphertext):
shift = int(shift_key_str[i % key_len])
# Calculate original character wrapping around the alphabet
orig_char = chr(((ord(char) - ord('A') - shift) % 26) + ord('A'))
plaintext += orig_char
return plaintext
def score_fitness(text):
"""
Checks for common bigrams and trigrams,
but also rewards the presence of common vowels.
"""
common_patterns = [
"TH", "HE", "IN", "ER", "AN", "RE", "ON", "AT", "EN",
"THE", "AND", "ING", "ENT", "ION", "HER", "FOR", "THA"
]
score = 0
for pattern in common_patterns:
score += text.count(pattern) * (len(pattern) ** 2) # Reward longer patterns more
# Bonus for common English vowels appearing frequently
vowels = "AEIOU"
vowel_count = sum(text.count(v) for v in vowels)
if vowel_count > (len(text) * 0.3): # English is usually ~38% vowels
score += 5
return score
def crack_zigzag(ciphertext, word_list, top_n=5):
"""
Brute-forces the cipher and returns the top N unique results.
"""
total_permutations = len(word_list) * 31
print(f"Cracking... Analyzing {total_permutations:,} possible combinations.\n")
all_results = []
for word in word_list:
for day in range(1, 32):
decrypted = agent_zigzag_decrypt(word, day, ciphertext)
score = score_fitness(decrypted)
all_results.append({
"keyword": word,
"day": day,
"text": decrypted,
"score": score
})
# Sort by highest score first
all_results.sort(key=lambda x: x['score'], reverse=True)
# Filter for unique plaintext results (different keys can result in the same text)
unique_results = []
seen_texts = set()
for res in all_results:
if res['text'] not in seen_texts:
unique_results.append(res)
seen_texts.add(res['text'])
if len(unique_results) >= top_n:
break
return unique_results
if __name__ == "__main__":
# 1. We start with an intercepted message (Ciphertext)
# This says "THE INVASION WILL HAPPEN AT DAWN" padded with "X"s
# Encrypted with keyword: "PARIS", Day: 6
intercepted_ciphertext = "UPMNS VBAQT SWJTT MFPQM VFYDB EVCCX"
# 2a. We load a small dictionary
# In a real scenario, you would load a text file with 100,000+ words here.
dictionary = [
"APPLE", "TRAIN", "BERLIN", "PARIS", "RADIO",
"LONDON", "CHURCHILL", "SPY", "ZIGZAG", "SECRET"
]
# 2b. Full dictionary loading example (commented out for this test)
# with open("english_words.txt", "r") as file:
# dictionary = [line.strip().upper() for line in file]
top_hits = crack_zigzag(intercepted_ciphertext, dictionary, top_n=5)
print(f"{'Rank':<5} | {'Score':<6} | {'Day':<3} | {'Keyword':<16} | {'Decrypted Message'}")
for i, hit in enumerate(top_hits, 1):
print(f"{i:<5} | {hit['score']:<6} | {hit['day']:<3} | {hit['keyword']:<16} | {hit['text']}")
How can the “Multiplication code” be strengthened?
To improve the cipher, we have to balance two competing needs: it must remain simple enough for a human to do with a pencil and paper, but it needs to disrupt the patterns that modern computers use to crack it. Here are the three ways to “patch” the multiplication code for the modern age:
Increase the “key space” with a secondary keyword
Currently, the date (1–31) is a very weak multiplier. A computer only has to check 31 possibilities per dictionary word.
The Improvement: Use a second memorised word as the multiplier.
- The old way:
Base number * 15 (day of month) - The new way:
Base number * base number of a second keyword - Why it works: If your second keyword is “ORANGE” (Base: 451326), your multiplier is now a six-digit number instead of a two-digit number. This blows the number of combinations from 6 million into the trillions, making a standard brute-force attack much slower.
Break the “Gronsfeld limit” (the +10 rule)
As discussed, the biggest flaw is that the shift is only 0–9. This means an “A” can never become a “Z”.
The Improvement: Use two digits of the product for every one letter of the message.
- The method: Instead of using the digits one by one
($7, 6, 8, 5...$), use them in pairs($76, 85, 10...$). - The math: Take the pair modulo 26.
76 / 26leaves a remainder of 24.85 / 26leaves a remainder of 7.
- Why it works: The shifts are now spread across the entire alphabet (0–25). A computer can no longer use statistical analysis to rule out letters like ‘Z’ or ‘X’.
“Double encryption” (Nihilist method)
This was a favourite of the Russian revolutionaries. It uses the same math but applies it twice to “smear” the frequency of letters.
The improvement: Encrypt the message twice using two different dates or two different keywords.
Why it works: In a single encryption, the relationship between “E” and its ciphertext counterpart is fixed for that message. In a double encryption, you create a “polyalphabetic” mess that is significantly harder for a frequency analysis script to latch onto.