Breaking the Caesar Cipher: Classical Encryption in Practice
Part 1 of 3 in Cryptography Basics: Ciphers, Modes, and Tampering
The Caesar cipher shifts every letter a fixed number of positions down the alphabet: pick a shift of 3 and A becomes D, B becomes E, and so on, wrapping Z back around to C. It’s one of the oldest ciphers on record, and it’s also one of the easiest to break with a laptop. This post builds it, breaks it, and pulls out the exact reason it (and every classical cipher) can’t be fixed.
The cipher, in code
A cipher is two functions: encryption turns plaintext into ciphertext, decryption reverses it. For Caesar, both are just a shift mod 26:
ALPHABET_SIZE = 26
A = ord('A')
def shift_char(c: str, shift: int) -> str:
"""Shift a single uppercase letter by `shift` positions, wrapping mod 26."""
if not c.isalpha():
return c
base = A if c.isupper() else ord('a')
return chr((ord(c) - base + shift) % ALPHABET_SIZE + base)
def encrypt(plaintext: str, key: int) -> str:
"""C = E(K, P): shift every letter forward by `key` positions."""
return ''.join(shift_char(c, key) for c in plaintext)
def decrypt(ciphertext: str, key: int) -> str:
"""P = D(K, C): shift every letter backward by `key` positions."""
return ''.join(shift_char(c, -key) for c in ciphertext)
Running it against a couple of quick sanity checks, shift 3 in both directions:
=== Encrypt/decrypt with the classic shift-3 key ===
encrypt('ZOO', key=3) -> 'CRR'
encrypt('CAESAR', key=3) -> 'FDHVDU'
decrypt('FDHVDU', key=3) -> 'CAESAR'
Nothing in the math requires the shift to be 3, that’s just the value that
happens to be traditional. shift_char above takes shift as a parameter
for exactly this reason: any integer from 0 to 25 produces a valid encryption.
That detail turns out to matter a lot.
Encrypting with a “secret” key
Since any shift value works, imagine trying to make the cipher a bit less trivial by picking a shift nobody would guess on sight, say 17, instead of the well-known 3:
secret_key = 17
message = "ATTACK AT DAWN"
ciphertext = encrypt(message, secret_key)
=== Encrypt a message with a 'secret' key (not 3) ===
plaintext: 'ATTACK AT DAWN'
key: 17 (assume attacker doesn't know this)
ciphertext: 'RKKRTB RK URNE'
RKKRTB RK URNE looks like nonsense. Looks secret. It isn’t.
Breaking it: try all 26 keys
There are only 26 possible keys total (0 through 25), so an attacker doesn’t need to guess at all: just run decryption once for every single one and read off which output looks like English:
def brute_force(ciphertext: str):
"""
Try every one of the 26 possible keys and return (key, plaintext) pairs.
This works because the Caesar cipher's key space has only 26 values.
No cleverness needed, just exhaustive search.
"""
return [(key, decrypt(ciphertext, key)) for key in range(ALPHABET_SIZE)]
Run against RKKRTB RK URNE from above, printing all 26 candidates:
=== Brute-force break: try all 26 possible keys ===
key= 0: RKKRTB RK URNE
key= 1: QJJQSA QJ TQMD
key= 2: PIIPRZ PI SPLC
key= 3: OHHOQY OH ROKB
key= 4: NGGNPX NG QNJA
key= 5: MFFMOW MF PMIZ
key= 6: LEELNV LE OLHY
key= 7: KDDKMU KD NKGX
key= 8: JCCJLT JC MJFW
key= 9: IBBIKS IB LIEV
key=10: HAAHJR HA KHDU
key=11: GZZGIQ GZ JGCT
key=12: FYYFHP FY IFBS
key=13: EXXEGO EX HEAR
key=14: DWWDFN DW GDZQ
key=15: CVVCEM CV FCYP
key=16: BUUBDL BU EBXO
key=17: ATTACK AT DAWN <-- looks like English
key=18: ZSSZBJ ZS CZVM
key=19: YRRYAI YR BYUL
key=20: XQQXZH XQ AXTK
key=21: WPPWYG WP ZWSJ
key=22: VOOVXF VO YVRI
key=23: UNNUWE UN XUQH
key=24: TMMTVD TM WTPG
key=25: SLLSUC SL VSOF
25 rows of gibberish, one row of ATTACK AT DAWN sticking out at key 17. No
key needed on the attacker’s side at all, only patience for 26 attempts,
which a computer clears in microseconds. Here’s the whole thing running
end to end:
Why this is unfixable, not just a bad implementation
It’s tempting to think the fix is “pick a better secret shift” or “keep the
shift value hidden.” Neither one works, and brute_force() above is the
whole reason why: hiding the shift only hides it from a human, not from a
script that tries every value in microseconds. The problem isn’t a leaked
key or a sloppy implementation, it’s that there are only 26 possible keys to
begin with.
Compare that to the space of all possible letter permutations of a 26-letter alphabet: 26! is roughly 2^88, an astronomically larger number. The Caesar cipher only ever reaches 26 of those roughly 2^88 permutations: the ones you get from shifting the whole alphabet by a fixed amount. A secure permutation needs to be determined by the key, so different keys must produce different permutations, and there need to be enough of them that guessing is infeasible. Caesar fails on volume alone: a keyspace of 26 is nothing to try exhaustively, whether by hand (tedious, but doable with a pen and a lookup table) or by script (as above, near instant). There’s no version of “shift the alphabet by a fixed amount” that escapes this: the mode of operation is fixed (repeat one permutation for every letter) and the permutation family itself only has 26 members. No secret survives a search space of 26.
Adding more shift values instead of one, so different letters in the same message shift by different amounts, buys a larger keyspace, but it turns out to trade one weakness for another: patterns in how often each shift repeats give an attacker enough structure to peel the key apart without ever brute forcing it. Fixing “too few keys” and fixing “too much structure in the ciphertext” are different problems, and ciphers limited to pen-and-paper operations can’t solve either one at real scale. That gap is exactly what motivates moving to modern ciphers with keys of 128 or 256 bits, a keyspace so large that brute-force search like the one above stops being a threat.