elethis-cipher/cipher/cipher.py
2022-08-03 00:32:29 -07:00

243 lines
7.8 KiB
Python

from collections import defaultdict
import re
class ElethisCipher:
"""
An implementation of Elethi's Cipher, a cipher created by Madame Elethi and
used in her ledger.
This is an autokey cipher that uses a variant on a tabula recta. Rather
than a typical 26x26 matrix with A-Z, Elethi's Cipher uses a 10x10 matrix
that encodes A-Z0-9:
0 1 2 3 4 5 6 7 8 9
+--------------------
0 | a b c d e f g h i j
1 | k l m n o p q r s t
2 | u v w x y z 0 1 2 3
3 | 4 5 6 7 8 9 a b c d
4 | e f g h i j k l m n
5 | o p q r s t u v w x
6 | y z 0 1 2 3 4 5 6 7
7 | 8 9 a b c d e f g h
8 | i j k l m n o p q r
9 | s t u v w x y z 0 1
Encrypting Messages
-------------------
The encryption algorithm is as follows. Consider the plaintext message
KILL KEEN FOR HANDSOME HENRY SMALLS
and the pre-shared key
SABETHA
1. Remove all non alpha-numeric characters from both the message and the
pre-shared key.
2. Create an encryption key by prefixing the plaintext message with a short
pre-shared key:
S A B E T H A K I L L K E E N F O ...
3. Locate each character of the key in the tabula recta and replace it with
a two-digit number comprised of the row coordinate followed by the column
coordinate:
S A B E T H A K I L L K E E N F O
18 00 01 04 19 07 00 10 08 11 11 10 04 04 13 05 14 ...
4. Convert the characters of the plaintext message in the same way:
K I L L K E E N F O
10 08 11 11 10 04 04 13 05 14 ...
5. Sum the value of each character in the plaintext message with the key:
K I L L K E E N F O
10 08 11 11 10 04 04 13 05 14
+ 18 00 01 04 19 07 00 10 08 11
-----------------------------
28 08 12 15 29 11 04 23 13 25
Note that tabula recta has multiple entries for each plaintext character;
any valid set of coordinates may be used to encode a given character, which
can be helpful when encoding repeating characters. From the above example,
We might choose to encode KEEN as follows:
K E E N
10 76 04 13
Decrypting Messages
-------------------
To decrypt the message, the recipient constructs the decryption key one
character at a time and subtracts the values from the encrypted characters:
1. Generate the start of the decryption key by converting the pre-shared
key into numbers using the tabula recta:
S A B E T H A
18 00 01 04 19 07 00
2. Subtract the first number of the key from the first number in the
message:
28
- 18
--
10
3. This number is appended to the decryption key:
S A B E T H A K
18 00 01 04 19 07 00 10
4. To decrypt the first character of the message, look up the number from
Step 2 in the tabula recta:
K
5. Repeat Steps 2-4 until the message has been decrypted, subtracting the
next value in the key from the next value in the encrypted message:
28 08 12 15 29 11 04 23 13 25
- 18 00 01 04 19 07 00 10 08 11
-----------------------------
10 08 11 11 10 04 04 13 05 14
K I L L K E E N F O
"""
alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789'
non_alphanum = re.compile('[^A-Za-z0-9]')
def __init__(self):
self._table = []
self._reversed = defaultdict(list)
self._reversed_index = defaultdict(list)
self._generate_tabula_recta()
def _generate_tabula_recta(self):
"""
Generate the tabula recta as a two-dimensional array, and a reverse lookup table as a dictionary.
"""
index = 0
for offset_y in range(0, 10):
row = ''
for offset_x in range(0, 10):
letter = self.alphabet[index]
row = row + letter
self._reversed[letter].append(f'{offset_y}{offset_x}')
index = index + 1
if index == len(self.alphabet):
index = 0
self._table.append(row)
@property
def tabula_recta(self):
"""
Return a formatted representation of the tabula recta.
"""
t = self._table.copy()
for (i, line) in enumerate(t):
t[i] = f'{i}|{line}'
t = '\n'.join([' '.join([col for col in row]) for row in t])
return f' 0 1 2 3 4 5 6 7 8 9\n +--------------------\n{t}'
def forward(self, n):
"""
Perform a forward lookup of a two digit number in the tabula recta and return the corresponding letter.
"""
n = int(n)
if n < 10:
x = 0
y = n
else:
y = n % 10
x = int((n - y)/10)
return self._table[x][y]
def reverse(self, letter):
"""
Perform a reverse lookup of a letter in the tabula recta and return a two-digit number corresponding to it.
"""
return int(self._reversed[letter][0])
def normalize(self, text):
"""
Normalize a string so that each character can be encrypted using the tabula recta.
"""
return self.non_alphanum.sub('', text).lower()
def encrypt(self, psk, message):
"""
Encrypt a message using the specified pre-shared key (psk). Returns a list of numbers.
"""
psk = self.normalize(psk)
plaintext = self.normalize(message)
key = [self.reverse(x) for x in psk + plaintext]
encrypted = []
for i in range(len(plaintext)):
a = self.reverse(plaintext[i])
x = int(a) + key[i]
encrypted.append(x)
return encrypted
def pretty_encrypt(self, psk, message, block_length=5, blocks_per_line=3):
"""
Encrypt a message using the specified pre-shared key (psk). Returns a formatted string consisting of the
encrypted message split into blocks of 3-digit numbers, 5 numbers to a block, 3 blocks to a line.
"""
psk = self.normalize(psk)
encrypted = [f'{x:03d}' for x in self.encrypt(psk, message)]
blocks = [encrypted[i:i + block_length] for i in range(0, len(encrypted), block_length)]
return '\n'.join([
' '.join([' '.join(inner) for inner in blocks[outer:outer + blocks_per_line]])
for outer in range(0, len(blocks), blocks_per_line)
])
def decrypt(self, psk, message):
"""
Decrypte a message using the specified pre-shared key (psk). Returns a list of characters.
"""
psk = self.normalize(psk)
plaintext = []
numbers = [int(x) for x in message.split()]
key = [self.reverse(x) for x in psk]
for (i, n) in enumerate(numbers):
k = key[0]
a = n - k
plaintext.append(self.forward(a).upper())
key = key[1:] + [a]
return plaintext
def pretty_decrypt(self, psk, message, block_length=5, blocks_per_line=3):
"""
Decryptes a message using the specified pre-shared key (psk). Returns a formatted string consisting of the
plaintext message split into blocks of single characters, 5 characters to a block, 3 blocks to a line.
"""
plaintext = self.decrypt(psk, message)
blocks = [plaintext[i:i + block_length] for i in range(0, len(plaintext), block_length)]
return '\n'.join([
' '.join([' '.join(inner) for inner in blocks[outer:outer + blocks_per_line]])
for outer in range(0, len(blocks), blocks_per_line)
])
def encrypt_file(self, psk, infile):
with open(infile) as f:
return self.pretty_encrypt(psk, f.read())
def decrypt_file(self, psk, infile):
with open(infile) as f:
return self.pretty_decrypt(psk, f.read())