Breaking 1941 German Army Ciphers
For the last few weeks my evenings have gone on something I didn’t expect: breaking genuine German Army radio messages from the summer and autumn of 1941. I was originally inspired by this Hacker News post about Carter Leffen using GPT-6 Astra to crack an as-yet unbroken message from 10th July 1941. Given I’ve been spending my tokens on making my homelab needlessly complicated ( read: cool ) I thought I could spare a few and give this a go myself.
After chatting with Claude and doing some of my own research I decided to initially focus on the Truppenschlüssel messages listed on CryptoCellar. Before we get into it, I want to make it clear that my use of AI was disclosed to Cryptocellar.
The challenge
CryptoCellar hosts a large collection of German Army messages from 1941, mostly from the radio net of the SS-Totenkopf-Division on the northern front. Some are Enigma; many are in the Truppenschlüssel (TS), a hand cipher closely related to the Doppelkasten (“double box”): the same pair of letter boxes, applied once instead of twice.
The TS is a two-square cipher. You get two 5×5 letter boxes as the day’s key, split the message into letter pairs, and replace each pair using the boxes. It’s weak by modern standards, but a short message with an unknown daily key is still a real puzzle: a 56-letter message is only 28 substitutions, and the key has 50 cells.
Here’s a real one to play with: message Nr. 218 of 17 July 1941, with the key and plaintext that Olaf Ostwald and Frode Weierud published in their paper Modern Cryptanalysis of the Truppenschlüssel. Step through it pair by pair to see how each substitution works, then look at the end of the message.
Step through the ciphertext one pair at a time, or decrypt it all at once.
The highlighted 14 letters repeat exactly: the clerk wrote the signature twice
(HARTIIENSTEIN X HARTIIENSTEIN X), and because the repeat starts on a pair boundary the cipher repeats
too. Codebreakers spotted repeats like this long before anyone knew the key.
Encrypt your own text with the same key
Rules the clerks used: no J (write II), spaces and full stops become X, CH is often written Q, and an odd-length text is padded with X.
When I started, the list had 136 TS messages, many still unbroken. There’s also the “Ultimate Enigma Challenge”, a handful of September 1941 Enigma messages nobody has read… yet.
Naivety
I’ve been known to drink the AI koolaid before now and I thought this would be easy. There are a few reasons why these messages haven’t been broken yet:
- Length: short messages make it much harder to use standard analytical attacks.
- Mistakes and garbles: messages were enciphered by hand, sent over the radio in Morse code, then written down and decrypted by hand. Each step is a chance for an error.
- Cribs: because of the above, you usually need a crib, i.e. some known (or likely) part of the plaintext at a known (or likely) position. Guessing “the” (or der/die/das) isn’t enough, because as you saw above a short word only covers two cipher pairs.
Ignoring all of this, I set Claude Opus 5.5 and Fable 5.1 on the task. They wrote some horrific CUDA code to use my PC’s GPU. I haven’t formatted this, and I won’t… it’s too disgusting, but at least there’s a comment!
int main(int argc,char**argv){
const char*A="ABCDEFGHIKLMNOPQRSTUVWXYZ";
if(argc<7){fprintf(stderr,"usage: tsgpu cipher tri seed nchains iters T0 [rounds]\n");return 1;}
std::vector<short> ct; short ms[64],ml[64]; int nm=0; char line[4096];
FILE*f=fopen(argv[1],"r");
while(fgets(line,sizeof line,f)){ if(line[0]=='#'||line[0]=='\n')continue; int field=0; ms[nm]=ct.size();
for(char*t=strtok(line," \t\n");t;t=strtok(NULL," \t\n"),field++){ if(field<2)continue;
for(char*c=t;*c;c++){ if(*c=='J'||*c=='-'||*c=='?') ct.push_back(-1); else if(*c>='A'&&*c<='Z') ct.push_back(strchr(A,*c)-A);} }
ml[nm]=ct.size()-ms[nm]; nm++; }
if(getenv("INITKEY")){ // start all non-fresh chains from this key in round 0 ("L1..L5/R1..R5" as rows "LLLLLRRRRR/...")
// ... and about 40 more lines like this
And it made no progress at all.
The archive
The division’s own files survive in the Bundesarchiv (the German Federal Archives) under RS 3-3, and many are digitised. I (well, Claude) downloaded the relevant files and requested reproductions of a few more. Three things in them turned out to be useful:
- Cipher forms with the operator’s decrypt pencilled on them. A radio operator received a TS message, decrypted it and wrote the German plaintext on the same form. A ciphertext plus its plaintext is a known-plaintext pair, and with a few of those the day’s key falls out.
- A clear-text log (RS 3-3/47b) kept by the quartermaster’s office: the decrypted text of months of messages from the supply commander, in reverse date order. It isn’t the exact enciphered wording, but it tells you what each message says.
- Both physical copies of some messages: the “red” received copy that CryptoCellar has, and a “blue” sender’s copy in the division files. They don’t always agree, and the differences matter.
The first breaks: early July
With cipher/plaintext pairs from the archive, the July keys came quickly. I recovered the full key for 4 July 1941 and its messages, then the keys for 3, 6, 8, 12 and 23 July. Frode confirmed them.
It turned out someone else had got to 4 July a few days earlier, and to the other days at around the same time. Several people broke the same messages within about a week of each other! Still, progress.
Enigma: two daily keys (re-broken)
On the Enigma side I recovered the 29 and 30 September 1941 keys for the supply commander’s net. Those messages were already broken; CryptoCellar lists them as solved. I needed the keys for a different reason: to test whether the unsolved Ultimate Enigma Challenge messages from the same two days were on the same net. (They aren’t.)
- A ciphertext-only attack on the GPU. It’s a CUDA reimplementation of the “solo” technique from Ostwald and Weierud’s Modern Breaking of Enigma Ciphertexts. It tries every wheel order, hill-climbs the plugboard and scores the result as German. At around 150 letters a true key stands clearly out of the noise.
- The three pencilled letters in the margin of the forms turned out to be the message keys (the rotor start positions). Once you know each message’s start position you can attack all of a day’s messages together, and that broke 30 September where the single-message attack had failed.
NEUEM: first to break one
NEUEM (25 July) was the first message I broke before anyone else. The log told me roughly what it said. Another breaker had already broken a different 25 July message, CPUDN, and I started from their key which was kindly shared with me. A joint GPU search over both messages, seeded with that key, converged on the same answer in all three runs:
KOLONNE FUER KORPSNAQSQUB WIRD IM LAUFE [DE]S NAQMITTAG IN MARSQ GESETZT X HARTIIENSTEIN
“Column for corps supply will be sent off during the afternoon. Hartjenstein.”
All but two of the 37 letter pairs fit one key. The other two are the operator’s own enciphering slips, identical in both copies. The same key then read CPUDN too, but only once I combined its red and blue copies to cancel out reception errors. That confirmed the key, and showed that the key we’d started from was only partly right.
CFQIB: easy, once you think like a clerk
CFQIB (29 July, 56 letters) had been broken by others shortly before, but nothing was published. The log says “743 F.H. Granaten eingegangen. gez. Hartjenstein”: 743 field-howitzer shells received. My earlier exact searches had “proved” that no natural wording fitted the cipher. The second time round it came out as:
SIEBEN VIER DREI X FH X X FH X GRANATEN EINGEGANGEN X HARTIIENSTEIN X
The clerk wrote the abbreviation twice, each copy framed by X’s (XFHX XFHX), just as they doubled place names elsewhere. My wording generator doubled numbers but never abbreviations, so it never tried this one. With the right wording, every one of the message’s 20 distinct letter pairs fits a single key, while scrambled “control” wordings with the same repeat pattern manage only 13–14.
What 1941 clerks actually wrote
The hard part is guessing exactly what the clerk enciphered. The habits I’ve confirmed so far:
- X for spaces and full stops, and usually at the very end.
- CH becomes Q: NAQMITTAG, MARSQ, KORPSNAQSQUB.
- J becomes II: HARTIIENSTEIN.
- Numbers spelled out digit by digit: SIEBEN VIER DREI, not SIEBENHUNDERT…
- Important words doubled: place names, abbreviations, signatures.
- Slips happen: operators sometimes mis-enciphered a pair or two.
None of this is new information, but the last one caught me out. An exact solver (z3, CP-SAT) will tell you, correctly, that a wording is inconsistent, but it can’t tell a wrong wording from a right wording with one slip. Switching to a soft solver, which maximises the number of consistent pairs and is compared against controls, is what finally made the late-July messages tractable.
Back to failure
VHRNE (31 July, 136 letters). Large parts are proven: “X PLESKAU X PLESKAU”, “X EIN X EINS EINS EINS…”. But the opening and the ending won’t fit any wording I’ve tried. I’ve compared both physical copies letter by letter and ruled out the obvious misreadings. It probably needs another message enciphered on the same day’s key, so I’m going back through the archive for one. (Spoiler: it fell the next day, and the reason it wouldn’t fit turned out to be the cipher, not the wording. More in the next post.)
The Ultimate Enigma Challenge. Every ciphertext-only run has come back negative: all 60 wheel orders with reflector B and with reflector C, every known 1941 key, and the two daily keys I broke. That’s still true after I found and fixed a rotor-wiring bug in my own tools and ran everything again. My working theory is that these messages aren’t on the Army net at all, and a couple don’t look like Enigma.
A toolkit
- One consumer GPU (an RTX 5080), shared through a simple job queue so long searches don’t trample each other.
- CUDA searches: simulated annealing for TS keys, with language-model scoring and crib support, plus Enigma “solo” and joint attacks.
- OR-Tools CP-SAT and z3 for exact and soft consistency checks.
- Local-only transcription. The archive images are for private research, so nothing goes to a cloud OCR service. I’m building a searchable corpus of every downloaded sheet in DuckDB. A local vision model is being tested for the form headers, and a small fine-tuned CNN reads about 88% of the pencilled cipher letters on sheets it hasn’t trained on, though it varies a lot between writers.
- Claude. A lot of this was done with Claude as a research partner: writing the CUDA, running the searches, reading the archive sheets, and getting things wrong, which a second model reviewing its work every few days caught at least twice.
What’s next
VHRNE is top of the list, and the rest of the late-July traffic after that. Almost every new archive file has turned up either a key or a reason my previous approach was wrong. If you’re interested in this sort of thing, CryptoCellar’s message lists show what’s still open.