{
  "id": "P022",
  "slug": "cipher-workbench",
  "key": "P022-cipher-workbench",
  "title": "Cipher workbench",
  "summary": "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.",
  "entry": "main.eml",
  "ui": "terminal",
  "readme": "# P022 - Cipher workbench\n\nEncode and decode text with two classical ciphers, Caesar and Vigenere, and\nbreak a Caesar cipher by frequency analysis. A toy for ciphers that have been\nbreakable for centuries - it is not security software and protects nothing.\nA text menu.\n\n- `main.eml` - the menu and its questions, with their checks\n- `cipher.eml` - a letter's place in the alphabet, shifting it, and the two\n  ciphers\n- `crack.eml` - letter counts, the English frequency table and the score of\n  every shift\n- `text.eml` - trimming and whole numbers\n\nA letter's place in the alphabet comes from a 26-letter table - the\ninterpreter that checks every session has no `ord` or `chr`. Case is kept,\nand anything that is not a letter passes through unchanged. Caesar moves\nevery letter the same number of places; Vigenere moves each letter by the\nplace of the next letter of the key (A = 0, B = 1, ...), and the key moves on\nonly at letters, so spaces and punctuation do not use it up. Decoding moves\neach letter back the same way.\n\nBreaking a Caesar cipher tries all 26 shifts. For each, the letters the text\nwould decode to are counted and compared with how often each letter appears\nin English (per 10,000 letters) by a chi-squared score - the smaller, the\nmore English. The score is worked out in whole numbers, each term rounded\ndown: it is chi-squared times the same constant for every shift, so it ranks\nthem the same way except, at most, between shifts that are all but tied. The\nthree best guesses are shown. Short texts are a known weakness of the method,\nand the program says so: with fewer than 20 letters the guess is unreliable -\nfor \"Khoor\" the right answer, \"Hello\", comes second.\n\nWhat is checked: a text has at most 300 characters; a shift is a whole\nnumber from 0 to 25; a key is 1 to 30 letters with no spaces, digits or\nsigns. Anything else asks again; nothing cancels. A text with no letters\ncannot be analysed.\n\nSessions: `sessions/basic.in` encodes and decodes \"Attack at dawn!\" with a\nshift of 3, the same text with the key LEMON (and decodes with the key in\nlower case), then breaks an 82-letter line of Dickens shifted by 7;\n`sessions/bad-input.in` types an empty text, shifts that are words or out of\nrange, keys with digits or spaces, a text with no letters, and asks to break\na five-letter word, where the best guess is wrong and the screen warns that\nit may be.\n\nBuilt on the verified corpus cases `caesar-cipher` (shifting by a 26-letter\ntable instead of ord and chr), `vigenere-cipher` (a repeating key giving a\ndifferent shift per letter) and `char-frequency-table` (counting\ncharacters).\n",
  "modules": [
    {
      "name": "main.eml",
      "eml": "# P022 cipher workbench: encode and decode with the Caesar and Vigenere\n# ciphers, and break a Caesar cipher by frequency analysis. Classical\n# ciphers as a toy - they protect nothing.\nimport cipher\nimport crack\nimport text\n\n300 => longest\n\ndef plural(n, word):\n    if n == 1:\n        return \"1 \" + word\n    return str(n) + \" \" + word + \"s\"\n\ndef ask_text():\n    # The text to work on, spaces kept, asked again if too long; \"\" cancels.\n    while True:\n        input(\"text> \") => t\n        if text.trim(t) == \"\":\n            return \"\"\n        if len(t) <= longest:\n            return t\n        (\"At most \" + str(longest) + \" characters.\") ^0\n\ndef ask_shift():\n    # A shift from 0 to 25, asked again until one is typed; -1 cancels.\n    while True:\n        text.trim(input(\"shift (0-25)> \")) => answer\n        if answer == \"\":\n            return 0 - 1\n        text.number(answer) => k\n        if k >= 0 and k <= 25:\n            return k\n        \"A shift is a whole number from 0 to 25, or nothing to cancel.\" ^0\n\ndef ask_key():\n    # A key of 1 to 30 letters, asked again until one is typed; \"\" cancels.\n    while True:\n        text.trim(input(\"key (letters only)> \")) => key\n        if key == \"\":\n            return \"\"\n        True => letters\n        for c in key:\n            if cipher.position(c)[0] == 0 - 1:\n                False => letters\n        if not letters:\n            \"A key has letters only - no spaces, digits or signs.\" ^0\n        elif len(key) > 30:\n            \"A key has at most 30 letters.\" ^0\n        else:\n            return key\n\nTrue => running\nwhile running:\n    \"\" ^0\n    \"== Cipher workbench ==\" ^0\n    \"1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\" ^0\n    \"5) break a Caesar cipher  6) quit\" ^0\n    text.trim(input(\"choice> \")) => choice\n    if choice == \"1\" or choice == \"2\":\n        ask_text() => t\n        0 - 1 => k\n        if t != \"\":\n            ask_shift() => k\n        if k == 0 - 1:\n            \"Cancelled.\" ^0\n        elif choice == \"1\":\n            (\"Encoded: \" + cipher.caesar(t, k)) ^0\n        else:\n            (\"Decoded: \" + cipher.caesar(t, (26 - k) % 26)) ^0\n    elif choice == \"3\" or choice == \"4\":\n        ask_text() => t\n        \"\" => key\n        if t != \"\":\n            ask_key() => key\n        if key == \"\":\n            \"Cancelled.\" ^0\n        elif choice == \"3\":\n            (\"Encoded: \" + cipher.vigenere(t, key, False)) ^0\n        else:\n            (\"Decoded: \" + cipher.vigenere(t, key, True)) ^0\n    elif choice == \"5\":\n        ask_text() => t\n        0 => letters\n        for n in crack.counts(t):\n            letters + n => letters\n        if t == \"\":\n            \"Cancelled.\" ^0\n        elif letters == 0:\n            \"No letters to analyse.\" ^0\n        else:\n            crack.ranked(t) => r\n            \"\" ^0\n            (\"-- best guesses, from \" + plural(letters, \"letter\") + \" --\") ^0\n            for i in [0:2]:\n                r[i][1] => s\n                (\"  shift \" + (\"%2d\" % s) + \": \" + cipher.caesar(t, (26 - s) % 26)) ^0\n            if letters < 20:\n                \"With fewer than 20 letters the guess is unreliable.\" ^0\n    elif choice == \"6\":\n        False => running\n    else:\n        \"Pick a number from 1 to 6.\" ^0\n\"Bye.\" ^0\n",
      "python": "import cipher\nimport crack\nimport text\nlongest = 300\n\ndef plural(n, word):\n    if n == 1:\n        return \"1 \" + word\n    return str(n) + \" \" + word + \"s\"\n\ndef ask_text():\n    while True:\n        t = input(\"text> \")\n        if text.trim(t) == \"\":\n            return \"\"\n        if len(t) <= longest:\n            return t\n        print(\"At most \" + str(longest) + \" characters.\")\n\ndef ask_shift():\n    while True:\n        answer = text.trim(input(\"shift (0-25)> \"))\n        if answer == \"\":\n            return 0 - 1\n        k = text.number(answer)\n        if k >= 0 and k <= 25:\n            return k\n        print(\"A shift is a whole number from 0 to 25, or nothing to cancel.\")\n\ndef ask_key():\n    while True:\n        key = text.trim(input(\"key (letters only)> \"))\n        if key == \"\":\n            return \"\"\n        letters = True\n        for c in key:\n            if cipher.position(c)[0] == 0 - 1:\n                letters = False\n        if not letters:\n            print(\"A key has letters only - no spaces, digits or signs.\")\n        elif len(key) > 30:\n            print(\"A key has at most 30 letters.\")\n        else:\n            return key\n\nrunning = True\nwhile running:\n    print(\"\")\n    print(\"== Cipher workbench ==\")\n    print(\"1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\")\n    print(\"5) break a Caesar cipher  6) quit\")\n    choice = text.trim(input(\"choice> \"))\n    if choice == \"1\" or choice == \"2\":\n        t = ask_text()\n        k = 0 - 1\n        if t != \"\":\n            k = ask_shift()\n        if k == 0 - 1:\n            print(\"Cancelled.\")\n        elif choice == \"1\":\n            print(\"Encoded: \" + cipher.caesar(t, k))\n        else:\n            print(\"Decoded: \" + cipher.caesar(t, (26 - k) % 26))\n    elif choice == \"3\" or choice == \"4\":\n        t = ask_text()\n        key = \"\"\n        if t != \"\":\n            key = ask_key()\n        if key == \"\":\n            print(\"Cancelled.\")\n        elif choice == \"3\":\n            print(\"Encoded: \" + cipher.vigenere(t, key, False))\n        else:\n            print(\"Decoded: \" + cipher.vigenere(t, key, True))\n    elif choice == \"5\":\n        t = ask_text()\n        letters = 0\n        for n in crack.counts(t):\n            letters = letters + n\n        if t == \"\":\n            print(\"Cancelled.\")\n        elif letters == 0:\n            print(\"No letters to analyse.\")\n        else:\n            r = crack.ranked(t)\n            print(\"\")\n            print(\"-- best guesses, from \" + plural(letters, \"letter\") + \" --\")\n            for i in range(0, 3):\n                s = r[i][1]\n                print(\"  shift \" + \"%2d\" % s + \": \" + cipher.caesar(t, (26 - s) % 26))\n            if letters < 20:\n                print(\"With fewer than 20 letters the guess is unreliable.\")\n    elif choice == \"6\":\n        running = False\n    else:\n        print(\"Pick a number from 1 to 6.\")\nprint(\"Bye.\")\n"
    },
    {
      "name": "cipher.eml",
      "eml": "# P022 cipher workbench - the Caesar and Vigenere ciphers. A letter's place in\n# the alphabet comes from a 26-letter table (the interpreter that checks every\n# session has no ord or chr); case is kept, and anything that is not a letter\n# passes through unchanged.\n\n\"abcdefghijklmnopqrstuvwxyz\" => lower_letters\n\"ABCDEFGHIJKLMNOPQRSTUVWXYZ\" => upper_letters\n\ndef position(c):\n    # [place 0-25, is a capital] for a letter, or [-1, False].\n    for i in [0:25]:\n        if lower_letters[i] == c:\n            return [i, False]\n        if upper_letters[i] == c:\n            return [i, True]\n    return [0 - 1, False]\n\ndef shifted(c, k):\n    # c moved k places along the alphabet (0 <= k <= 25), wrapping round.\n    position(c) => p\n    if p[0] == 0 - 1:\n        return c\n    (p[0] + k) % 26 => j\n    if p[1]:\n        return upper_letters[j]\n    return lower_letters[j]\n\ndef caesar(text, k):\n    \"\" => out\n    for c in text:\n        out + shifted(c, k) => out\n    return out\n\ndef vigenere(text, key, decode):\n    # Each letter is moved by the place of the next key letter; the key moves\n    # on only at letters. Decoding moves each letter back the same way.\n    \"\" => out\n    0 => j\n    for c in text:\n        if position(c)[0] == 0 - 1:\n            out + c => out\n        else:\n            position(key[j % len(key)])[0] => k\n            if decode:\n                (26 - k) % 26 => k\n            out + shifted(c, k) => out\n            j + 1 => j\n    return out\n",
      "python": "lower_letters = \"abcdefghijklmnopqrstuvwxyz\"\nupper_letters = \"ABCDEFGHIJKLMNOPQRSTUVWXYZ\"\n\ndef position(c):\n    for i in range(0, 26):\n        if lower_letters[i] == c:\n            return [i, False]\n        if upper_letters[i] == c:\n            return [i, True]\n    return [0 - 1, False]\n\ndef shifted(c, k):\n    p = position(c)\n    if p[0] == 0 - 1:\n        return c\n    j = (p[0] + k) % 26\n    if p[1]:\n        return upper_letters[j]\n    return lower_letters[j]\n\ndef caesar(text, k):\n    out = \"\"\n    for c in text:\n        out = out + shifted(c, k)\n    return out\n\ndef vigenere(text, key, decode):\n    out = \"\"\n    j = 0\n    for c in text:\n        if position(c)[0] == 0 - 1:\n            out = out + c\n        else:\n            k = position(key[j % len(key)])[0]\n            if decode:\n                k = (26 - k) % 26\n            out = out + shifted(c, k)\n            j = j + 1\n    return out\n"
    },
    {
      "name": "crack.eml",
      "eml": "# P022 cipher workbench - breaking a Caesar cipher by frequency analysis. For\n# every shift, the letters the text would decode to are counted and compared\n# with how often each letter appears in English (per 10,000 letters) by a\n# chi-squared score: the smaller the score, the more English the result looks.\n# The score is worked out in whole numbers - the sum of (10000 x count -\n# letters x expected)^2 / expected, each term rounded down - which is\n# chi-squared times the same constant for every shift, so it ranks them the\n# same way except, at most, between shifts that are all but tied.\nimport cipher\n\n[817, 149, 278, 425, 1270, 223, 202, 609, 697, 15, 77, 403, 241,\n 675, 751, 193, 10, 599, 633, 906, 276, 98, 236, 15, 197, 7] => english\n\ndef counts(text):\n    # How many of each letter a-z the text has, ignoring case.\n    [] => out\n    for i in [0:25]:\n        out + [0] => out\n    for c in text:\n        cipher.position(c)[0] => p\n        if p != 0 - 1:\n            out[p] + 1 => out[p]\n    return out\n\ndef score(cnt, letters, shift):\n    # The score of the text decoded by moving each letter back shift places:\n    # decoded letter i comes from cipher letter i + shift.\n    0 => total\n    for i in [0:25]:\n        10000 * cnt[(i + shift) % 26] - letters * english[i] => d\n        d * d => sq\n        total + int((sq - sq % english[i]) / english[i]) => total\n    return total\n\ndef ranked(text):\n    # [score, shift] for every shift 0-25, best (lowest score) first; equal\n    # scores keep the smaller shift first.\n    counts(text) => cnt\n    0 => letters\n    for n in cnt:\n        letters + n => letters\n    [] => out\n    for s in [0:25]:\n        [score(cnt, letters, s), s] => item\n        len(out) => p\n        out + [item] => out\n        while p > 0 and out[p - 1][0] > item[0]:\n            out[p - 1] => out[p]\n            p - 1 => p\n        item => out[p]\n    return out\n",
      "python": "import cipher\nenglish = [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]\n\ndef counts(text):\n    out = []\n    for i in range(0, 26):\n        out = out + [0]\n    for c in text:\n        p = cipher.position(c)[0]\n        if p != 0 - 1:\n            out[p] = out[p] + 1\n    return out\n\ndef score(cnt, letters, shift):\n    total = 0\n    for i in range(0, 26):\n        d = 10000 * cnt[(i + shift) % 26] - letters * english[i]\n        sq = d * d\n        total = total + int((sq - sq % english[i]) / english[i])\n    return total\n\ndef ranked(text):\n    cnt = counts(text)\n    letters = 0\n    for n in cnt:\n        letters = letters + n\n    out = []\n    for s in range(0, 26):\n        item = [score(cnt, letters, s), s]\n        p = len(out)\n        out = out + [item]\n        while p > 0 and out[p - 1][0] > item[0]:\n            out[p] = out[p - 1]\n            p = p - 1\n        out[p] = item\n    return out\n"
    },
    {
      "name": "text.eml",
      "eml": "# P022 cipher workbench - reading what is typed. The interpreter that checks\n# every session does not run string methods yet, so the text handling is\n# written out here.\n\ndef trim(s):\n    # s without the spaces at either end.\n    0 => i\n    len(s) => j\n    while i < j and s[i] == \" \":\n        i + 1 => i\n    while j > i and s[j - 1] == \" \":\n        j - 1 => j\n    return s[i:j]\n\ndef number(s):\n    # The value of s if it is digits only (at least one), otherwise -1.\n    if s == \"\":\n        return 0 - 1\n    0 => n\n    for c in s:\n        if not (c in \"0123456789\"):\n            return 0 - 1\n        n * 10 + int(c) => n\n    return n\n",
      "python": "def trim(s):\n    i = 0\n    j = len(s)\n    while i < j and s[i] == \" \":\n        i = i + 1\n    while j > i and s[j - 1] == \" \":\n        j = j - 1\n    return s[i:j]\n\ndef number(s):\n    if s == \"\":\n        return 0 - 1\n    n = 0\n    for c in s:\n        if not c in \"0123456789\":\n            return 0 - 1\n        n = n * 10 + int(c)\n    return n\n"
    }
  ],
  "sessions": [
    {
      "name": "bad-input",
      "input": "0\n1\n\n1\nHello\nx\n26\n-1\n3\n3\nHello\nL3MON\nkey with spaces\n\n5\n12345!\n5\nKhoor\n6\n",
      "screen": "\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 0\nPick a number from 1 to 6.\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 1\ntext> \nCancelled.\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 1\ntext> Hello\nshift (0-25)> x\nA shift is a whole number from 0 to 25, or nothing to cancel.\nshift (0-25)> 26\nA shift is a whole number from 0 to 25, or nothing to cancel.\nshift (0-25)> -1\nA shift is a whole number from 0 to 25, or nothing to cancel.\nshift (0-25)> 3\nEncoded: Khoor\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 3\ntext> Hello\nkey (letters only)> L3MON\nA key has letters only - no spaces, digits or signs.\nkey (letters only)> key with spaces\nA key has letters only - no spaces, digits or signs.\nkey (letters only)> \nCancelled.\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 5\ntext> 12345!\nNo letters to analyse.\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 5\ntext> Khoor\n\n-- best guesses, from 5 letters --\n  shift 14: Wtaad\n  shift  3: Hello\n  shift  6: Ebiil\nWith fewer than 20 letters the guess is unreliable.\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 6\nBye.\n",
      "interpreter": "equal"
    },
    {
      "name": "basic",
      "input": "1\nAttack at dawn!\n3\n2\nDwwdfn dw gdzq!\n3\n3\nAttack at dawn\nLEMON\n4\nLxfopv ef rnhr\nlemon\n5\nPa dhz aol ilza vm aptlz, pa dhz aol dvyza vm aptlz; pa dhz aol hnl vm dpzkvt, pa dhz aol hnl vm mvvspzoulzz.\n6\n",
      "screen": "\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 1\ntext> Attack at dawn!\nshift (0-25)> 3\nEncoded: Dwwdfn dw gdzq!\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 2\ntext> Dwwdfn dw gdzq!\nshift (0-25)> 3\nDecoded: Attack at dawn!\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 3\ntext> Attack at dawn\nkey (letters only)> LEMON\nEncoded: Lxfopv ef rnhr\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 4\ntext> Lxfopv ef rnhr\nkey (letters only)> lemon\nDecoded: Attack at dawn\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 5\ntext> Pa dhz aol ilza vm aptlz, pa dhz aol dvyza vm aptlz; pa dhz aol hnl vm dpzkvt, pa dhz aol hnl vm mvvspzoulzz.\n\n-- best guesses, from 82 letters --\n  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.\n  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.\n  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.\n\n== Cipher workbench ==\n1) Caesar encode  2) Caesar decode  3) Vigenere encode  4) Vigenere decode\n5) break a Caesar cipher  6) quit\nchoice> 6\nBye.\n",
      "interpreter": "equal"
    }
  ],
  "builtOn": [
    {
      "slug": "caesar-cipher",
      "caseId": "015-caesar-cipher",
      "title": "Caesar cipher"
    },
    {
      "slug": "vigenere-cipher",
      "caseId": "109-vigenere-cipher",
      "title": "Vigenère cipher"
    },
    {
      "slug": "char-frequency-table",
      "caseId": "157-char-frequency-table",
      "title": "Counting with a dict, in first-appearance order"
    }
  ],
  "updated": "2026-10-03"
}
