Cipher workbench
Encode and decode with the Caesar and Vigenere ciphers - case and punctuation kept, the Vigenere key moving only on letters - and break a Caesar cipher by comparing letter counts with English for every shift; a toy for classical ciphers, not security software, from a text menu.
Every screen below was recorded under CPython. When this page was built, the EML interpreter replayed each session from the same input and printed the same bytes.
About
Encode and decode text with two classical ciphers, Caesar and Vigenere, and break a Caesar cipher by frequency analysis. A toy for ciphers that have been breakable for centuries - it is not security software and protects nothing. A text menu.
main.eml- the menu and its questions, with their checkscipher.eml- a letter's place in the alphabet, shifting it, and the two cipherscrack.eml- letter counts, the English frequency table and the score of every shifttext.eml- trimming and whole numbers
A letter's place in the alphabet comes from a 26-letter table - the interpreter that checks every session has no ord or chr. Case is kept, and anything that is not a letter passes through unchanged. Caesar moves every letter the same number of places; Vigenere moves each letter by the place of the next letter of the key (A = 0, B = 1, ...), and the key moves on only at letters, so spaces and punctuation do not use it up. Decoding moves each letter back the same way.
Breaking a Caesar cipher tries all 26 shifts. For each, the letters the text would decode to are counted and compared with how often each letter appears in English (per 10,000 letters) by a chi-squared score - the smaller, the more English. The score is worked out in whole numbers, each term rounded down: it is chi-squared times the same constant for every shift, so it ranks them the same way except, at most, between shifts that are all but tied. The three best guesses are shown. Short texts are a known weakness of the method, and the program says so: with fewer than 20 letters the guess is unreliable - for "Khoor" the right answer, "Hello", comes second.
What is checked: a text has at most 300 characters; a shift is a whole number from 0 to 25; a key is 1 to 30 letters with no spaces, digits or signs. Anything else asks again; nothing cancels. A text with no letters cannot be analysed.
Sessions: sessions/basic.in encodes and decodes "Attack at dawn!" with a shift of 3, the same text with the key LEMON (and decodes with the key in lower case), then breaks an 82-letter line of Dickens shifted by 7; sessions/bad-input.in types an empty text, shifts that are words or out of range, keys with digits or spaces, a text with no letters, and asks to break a five-letter word, where the best guess is wrong and the screen warns that it may be.
Built on the verified corpus cases caesar-cipher (shifting by a 26-letter table instead of ord and chr), vigenere-cipher (a repeating key giving a different shift per letter) and char-frequency-table (counting characters).
Recorded sessions
What the screen shows while someone uses the program. Each typed line appears after its prompt, the way a terminal shows it.
bad-input
interpreter: byte-equal
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 0
Pick a number from 1 to 6.
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 1
text>
Cancelled.
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 1
text> Hello
shift (0-25)> x
A shift is a whole number from 0 to 25, or nothing to cancel.
shift (0-25)> 26
A shift is a whole number from 0 to 25, or nothing to cancel.
shift (0-25)> -1
A shift is a whole number from 0 to 25, or nothing to cancel.
shift (0-25)> 3
Encoded: Khoor
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 3
text> Hello
key (letters only)> L3MON
A key has letters only - no spaces, digits or signs.
key (letters only)> key with spaces
A key has letters only - no spaces, digits or signs.
key (letters only)>
Cancelled.
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 5
text> 12345!
No letters to analyse.
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 5
text> Khoor
-- best guesses, from 5 letters --
shift 14: Wtaad
shift 3: Hello
shift 6: Ebiil
With fewer than 20 letters the guess is unreliable.
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 6
Bye.
What was typed (19 lines)
0
1
1
Hello
x
26
-1
3
3
Hello
L3MON
key with spaces
5
12345!
5
Khoor
6
basic
interpreter: byte-equal
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 1
text> Attack at dawn!
shift (0-25)> 3
Encoded: Dwwdfn dw gdzq!
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 2
text> Dwwdfn dw gdzq!
shift (0-25)> 3
Decoded: Attack at dawn!
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 3
text> Attack at dawn
key (letters only)> LEMON
Encoded: Lxfopv ef rnhr
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 4
text> Lxfopv ef rnhr
key (letters only)> lemon
Decoded: Attack at dawn
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 5
text> Pa dhz aol ilza vm aptlz, pa dhz aol dvyza vm aptlz; pa dhz aol hnl vm dpzkvt, pa dhz aol hnl vm mvvspzoulzz.
-- best guesses, from 82 letters --
shift 7: It was the best of times, it was the worst of times; it was the age of wisdom, it was the age of foolishness.
shift 19: Wh kog hvs psgh ct hwasg, wh kog hvs kcfgh ct hwasg; wh kog hvs ous ct kwgrca, wh kog hvs ous ct tcczwgvbsgg.
shift 11: Ep swo pda xaop kb peiao, ep swo pda sknop kb peiao; ep swo pda wca kb seozki, ep swo pda wca kb bkkheodjaoo.
== Cipher workbench ==
1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode
5) break a Caesar cipher 6) quit
choice> 6
Bye.
What was typed (15 lines)
1
Attack at dawn!
3
2
Dwwdfn dw gdzq!
3
3
Attack at dawn
LEMON
4
Lxfopv ef rnhr
lemon
5
Pa dhz aol ilza vm aptlz, pa dhz aol dvyza vm aptlz; pa dhz aol hnl vm dpzkvt, pa dhz aol hnl vm mvvspzoulzz.
6
Modules
The program as written, entry module first. Each module transpiles to its own Python file, which is what eml project run executes.
main.eml(entry)
eml# P022 cipher workbench: encode and decode with the Caesar and Vigenere
# ciphers, and break a Caesar cipher by frequency analysis. Classical
# ciphers as a toy - they protect nothing.
import cipher
import crack
import text
300 => longest
def plural(n, word):
if n == 1:
return "1 " + word
return str(n) + " " + word + "s"
def ask_text():
# The text to work on, spaces kept, asked again if too long; "" cancels.
while True:
input("text> ") => t
if text.trim(t) == "":
return ""
if len(t) <= longest:
return t
("At most " + str(longest) + " characters.") ^0
def ask_shift():
# A shift from 0 to 25, asked again until one is typed; -1 cancels.
while True:
text.trim(input("shift (0-25)> ")) => answer
if answer == "":
return 0 - 1
text.number(answer) => k
if k >= 0 and k <= 25:
return k
"A shift is a whole number from 0 to 25, or nothing to cancel." ^0
def ask_key():
# A key of 1 to 30 letters, asked again until one is typed; "" cancels.
while True:
text.trim(input("key (letters only)> ")) => key
if key == "":
return ""
True => letters
for c in key:
if cipher.position(c)[0] == 0 - 1:
False => letters
if not letters:
"A key has letters only - no spaces, digits or signs." ^0
elif len(key) > 30:
"A key has at most 30 letters." ^0
else:
return key
True => running
while running:
"" ^0
"== Cipher workbench ==" ^0
"1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode" ^0
"5) break a Caesar cipher 6) quit" ^0
text.trim(input("choice> ")) => choice
if choice == "1" or choice == "2":
ask_text() => t
0 - 1 => k
if t != "":
ask_shift() => k
if k == 0 - 1:
"Cancelled." ^0
elif choice == "1":
("Encoded: " + cipher.caesar(t, k)) ^0
else:
("Decoded: " + cipher.caesar(t, (26 - k) % 26)) ^0
elif choice == "3" or choice == "4":
ask_text() => t
"" => key
if t != "":
ask_key() => key
if key == "":
"Cancelled." ^0
elif choice == "3":
("Encoded: " + cipher.vigenere(t, key, False)) ^0
else:
("Decoded: " + cipher.vigenere(t, key, True)) ^0
elif choice == "5":
ask_text() => t
0 => letters
for n in crack.counts(t):
letters + n => letters
if t == "":
"Cancelled." ^0
elif letters == 0:
"No letters to analyse." ^0
else:
crack.ranked(t) => r
"" ^0
("-- best guesses, from " + plural(letters, "letter") + " --") ^0
for i in [0:2]:
r[i][1] => s
(" shift " + ("%2d" % s) + ": " + cipher.caesar(t, (26 - s) % 26)) ^0
if letters < 20:
"With fewer than 20 letters the guess is unreliable." ^0
elif choice == "6":
False => running
else:
"Pick a number from 1 to 6." ^0
"Bye." ^0
Python projection (main.py)
import cipher
import crack
import text
longest = 300
def plural(n, word):
if n == 1:
return "1 " + word
return str(n) + " " + word + "s"
def ask_text():
while True:
t = input("text> ")
if text.trim(t) == "":
return ""
if len(t) <= longest:
return t
print("At most " + str(longest) + " characters.")
def ask_shift():
while True:
answer = text.trim(input("shift (0-25)> "))
if answer == "":
return 0 - 1
k = text.number(answer)
if k >= 0 and k <= 25:
return k
print("A shift is a whole number from 0 to 25, or nothing to cancel.")
def ask_key():
while True:
key = text.trim(input("key (letters only)> "))
if key == "":
return ""
letters = True
for c in key:
if cipher.position(c)[0] == 0 - 1:
letters = False
if not letters:
print("A key has letters only - no spaces, digits or signs.")
elif len(key) > 30:
print("A key has at most 30 letters.")
else:
return key
running = True
while running:
print("")
print("== Cipher workbench ==")
print("1) Caesar encode 2) Caesar decode 3) Vigenere encode 4) Vigenere decode")
print("5) break a Caesar cipher 6) quit")
choice = text.trim(input("choice> "))
if choice == "1" or choice == "2":
t = ask_text()
k = 0 - 1
if t != "":
k = ask_shift()
if k == 0 - 1:
print("Cancelled.")
elif choice == "1":
print("Encoded: " + cipher.caesar(t, k))
else:
print("Decoded: " + cipher.caesar(t, (26 - k) % 26))
elif choice == "3" or choice == "4":
t = ask_text()
key = ""
if t != "":
key = ask_key()
if key == "":
print("Cancelled.")
elif choice == "3":
print("Encoded: " + cipher.vigenere(t, key, False))
else:
print("Decoded: " + cipher.vigenere(t, key, True))
elif choice == "5":
t = ask_text()
letters = 0
for n in crack.counts(t):
letters = letters + n
if t == "":
print("Cancelled.")
elif letters == 0:
print("No letters to analyse.")
else:
r = crack.ranked(t)
print("")
print("-- best guesses, from " + plural(letters, "letter") + " --")
for i in range(0, 3):
s = r[i][1]
print(" shift " + "%2d" % s + ": " + cipher.caesar(t, (26 - s) % 26))
if letters < 20:
print("With fewer than 20 letters the guess is unreliable.")
elif choice == "6":
running = False
else:
print("Pick a number from 1 to 6.")
print("Bye.")
cipher.eml
eml# P022 cipher workbench - the Caesar and Vigenere ciphers. A letter's place in
# the alphabet comes from a 26-letter table (the interpreter that checks every
# session has no ord or chr); case is kept, and anything that is not a letter
# passes through unchanged.
"abcdefghijklmnopqrstuvwxyz" => lower_letters
"ABCDEFGHIJKLMNOPQRSTUVWXYZ" => upper_letters
def position(c):
# [place 0-25, is a capital] for a letter, or [-1, False].
for i in [0:25]:
if lower_letters[i] == c:
return [i, False]
if upper_letters[i] == c:
return [i, True]
return [0 - 1, False]
def shifted(c, k):
# c moved k places along the alphabet (0 <= k <= 25), wrapping round.
position(c) => p
if p[0] == 0 - 1:
return c
(p[0] + k) % 26 => j
if p[1]:
return upper_letters[j]
return lower_letters[j]
def caesar(text, k):
"" => out
for c in text:
out + shifted(c, k) => out
return out
def vigenere(text, key, decode):
# Each letter is moved by the place of the next key letter; the key moves
# on only at letters. Decoding moves each letter back the same way.
"" => out
0 => j
for c in text:
if position(c)[0] == 0 - 1:
out + c => out
else:
position(key[j % len(key)])[0] => k
if decode:
(26 - k) % 26 => k
out + shifted(c, k) => out
j + 1 => j
return out
Python projection (cipher.py)
lower_letters = "abcdefghijklmnopqrstuvwxyz"
upper_letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
def position(c):
for i in range(0, 26):
if lower_letters[i] == c:
return [i, False]
if upper_letters[i] == c:
return [i, True]
return [0 - 1, False]
def shifted(c, k):
p = position(c)
if p[0] == 0 - 1:
return c
j = (p[0] + k) % 26
if p[1]:
return upper_letters[j]
return lower_letters[j]
def caesar(text, k):
out = ""
for c in text:
out = out + shifted(c, k)
return out
def vigenere(text, key, decode):
out = ""
j = 0
for c in text:
if position(c)[0] == 0 - 1:
out = out + c
else:
k = position(key[j % len(key)])[0]
if decode:
k = (26 - k) % 26
out = out + shifted(c, k)
j = j + 1
return out
crack.eml
eml# P022 cipher workbench - breaking a Caesar cipher by frequency analysis. For
# every shift, the letters the text would decode to are counted and compared
# with how often each letter appears in English (per 10,000 letters) by a
# chi-squared score: the smaller the score, the more English the result looks.
# The score is worked out in whole numbers - the sum of (10000 x count -
# letters x expected)^2 / expected, each term rounded down - which is
# chi-squared times the same constant for every shift, so it ranks them the
# same way except, at most, between shifts that are all but tied.
import cipher
[817, 149, 278, 425, 1270, 223, 202, 609, 697, 15, 77, 403, 241,
675, 751, 193, 10, 599, 633, 906, 276, 98, 236, 15, 197, 7] => english
def counts(text):
# How many of each letter a-z the text has, ignoring case.
[] => out
for i in [0:25]:
out + [0] => out
for c in text:
cipher.position(c)[0] => p
if p != 0 - 1:
out[p] + 1 => out[p]
return out
def score(cnt, letters, shift):
# The score of the text decoded by moving each letter back shift places:
# decoded letter i comes from cipher letter i + shift.
0 => total
for i in [0:25]:
10000 * cnt[(i + shift) % 26] - letters * english[i] => d
d * d => sq
total + int((sq - sq % english[i]) / english[i]) => total
return total
def ranked(text):
# [score, shift] for every shift 0-25, best (lowest score) first; equal
# scores keep the smaller shift first.
counts(text) => cnt
0 => letters
for n in cnt:
letters + n => letters
[] => out
for s in [0:25]:
[score(cnt, letters, s), s] => item
len(out) => p
out + [item] => out
while p > 0 and out[p - 1][0] > item[0]:
out[p - 1] => out[p]
p - 1 => p
item => out[p]
return out
Python projection (crack.py)
import cipher
english = [817, 149, 278, 425, 1270, 223, 202, 609, 697, 15, 77, 403, 241, 675, 751, 193, 10, 599, 633, 906, 276, 98, 236, 15, 197, 7]
def counts(text):
out = []
for i in range(0, 26):
out = out + [0]
for c in text:
p = cipher.position(c)[0]
if p != 0 - 1:
out[p] = out[p] + 1
return out
def score(cnt, letters, shift):
total = 0
for i in range(0, 26):
d = 10000 * cnt[(i + shift) % 26] - letters * english[i]
sq = d * d
total = total + int((sq - sq % english[i]) / english[i])
return total
def ranked(text):
cnt = counts(text)
letters = 0
for n in cnt:
letters = letters + n
out = []
for s in range(0, 26):
item = [score(cnt, letters, s), s]
p = len(out)
out = out + [item]
while p > 0 and out[p - 1][0] > item[0]:
out[p] = out[p - 1]
p = p - 1
out[p] = item
return out
text.eml
eml# P022 cipher workbench - reading what is typed. The interpreter that checks
# every session does not run string methods yet, so the text handling is
# written out here.
def trim(s):
# s without the spaces at either end.
0 => i
len(s) => j
while i < j and s[i] == " ":
i + 1 => i
while j > i and s[j - 1] == " ":
j - 1 => j
return s[i:j]
def number(s):
# The value of s if it is digits only (at least one), otherwise -1.
if s == "":
return 0 - 1
0 => n
for c in s:
if not (c in "0123456789"):
return 0 - 1
n * 10 + int(c) => n
return n
Python projection (text.py)
def trim(s):
i = 0
j = len(s)
while i < j and s[i] == " ":
i = i + 1
while j > i and s[j - 1] == " ":
j = j - 1
return s[i:j]
def number(s):
if s == "":
return 0 - 1
n = 0
for c in s:
if not c in "0123456789":
return 0 - 1
n = n * 10 + int(c)
return n