Project P022

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.

4 modules · 2 recorded sessionstext-menu UI in the terminalupdated 2026-10-03

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 checks
  • cipher.eml - a letter's place in the alphabet, shifting it, and the two ciphers
  • crack.eml - letter counts, the English frequency table and the score of every shift
  • text.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

Built on these corpus cases